Compare commits

..
827 Commits
Author SHA1 Message Date
Iuliia Shnai 4f95f0b54d feat: two emails for abandoned checkout 2026-01-29 14:15:14 +11:00
Marc SeitzandGitHub 957dd75754 Merge pull request #2018 from mfts/fix/revalidation
fix: add revalidation on link creation
2026-01-28 22:37:57 +11:00
Marc Seitz caed976656 fix: add revalidation on link creation 2026-01-28 21:49:35 +11:00
Marc SeitzandGitHub 347d339c75 Merge pull request #2017 from mfts/fix/truncate-safari
fix: safari rendering
2026-01-28 12:20:25 +11:00
Marc Seitz a014ff9bfb fix: safari rendering 2026-01-28 12:07:14 +11:00
Marc SeitzandGitHub 58f18f1a32 Merge pull request #2016 from mfts/fix/lastupdated
feat: hide document/folder updated at when hidden in dataroom
2026-01-28 11:46:18 +11:00
Marc SeitzandGitHub ccb4f8070a Merge pull request #2015 from mfts/fix/remove-annotations
fix: remove annotations from view
2026-01-28 11:33:32 +11:00
Marc Seitz d00e56cd90 feat: hide document/folder updated at when hidden in dataroom 2026-01-28 11:32:49 +11:00
Marc Seitz 0329695864 fix: remove annotations from view 2026-01-28 11:18:20 +11:00
Marc SeitzandGitHub 10d00277bf Merge pull request #2014 from mfts/cursor/link-expiration-indicator-1721
Link expiration indicator
2026-01-28 11:17:07 +13:00
Marc Seitz 28e07d0ff9 feat: add expires/expired link tooltips 2026-01-28 09:10:37 +11:00
Marc SeitzandGitHub b46a29037a Merge pull request #2013 from mfts/fix/notion
fix: notion width and missing page references
2026-01-28 10:55:28 +13:00
Cursor Agentandmarcftone e74f80abff Use TimerOff and Hourglass icons for link expiration states
- TimerOffIcon for expired links (red/destructive styling)
- HourglassIcon for links with expiration set but not yet expired (orange styling)
- Both show timestamp tooltip with expiration date and relative time

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-27 21:50:03 +00:00
Marc Seitz bd7192a402 fix 2026-01-28 08:47:47 +11:00
Cursor Agentandmarcftone 5dc89f8614 Add expired indicator to link table with timestamp tooltip
- Show 'Expired' badge with clock icon when a link's expiresAt date is in the past
- Use TimestampTooltip to show when the link expired (local time, UTC)
- The tooltip also shows relative time (how long ago it expired)
- Badge uses destructive/red styling to clearly indicate the expired state

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-26 11:17:07 +00:00
Marc Seitz ad4fb6e949 fix: notion width 2026-01-26 23:42:11 +13:00
Marc Seitz bc39c21b0e fix: fetch missing notion page references 2026-01-26 23:03:01 +13:00
Marc SeitzandGitHub 6d1da07779 Merge pull request #2012 from mfts/chore/depps
chore: update dependencies
2026-01-25 17:45:18 +13:00
Marc Seitz 6a0393fa63 chore: update dependencies 2026-01-25 17:18:46 +13:00
Marc SeitzandGitHub 99b3207691 Merge pull request #2010 from mfts/cursor/delayed-email-verification-notice-f9a1
Delayed email verification notice
2026-01-24 18:07:46 +13:00
Marc SeitzandGitHub cad9b67d69 Merge pull request #2011 from mfts/fix/ai-generate
fix: recursive reference
2026-01-24 18:00:05 +13:00
Cursor Agentandmarcftone 1544e9569d Change notice delay from 5 to 10 seconds
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 04:56:17 +00:00
Marc SeitzandGitHub c1d050c479 Merge pull request #2009 from mfts/cursor/notion-mobile-table-overflow-b18b
Notion mobile table overflow
2026-01-24 17:51:02 +13:00
Marc Seitz 33907e5ba4 fix: recursive reference 2026-01-24 17:50:29 +13:00
Cursor Agentandmarcftone 6292caa53a Fix notice state persisting across lock/unlock cycles
- Reset showEmailDeliveryNotice to false when emailLocked becomes false
- Timer only starts when emailLocked is true
- Ensures notice resets on unlock and re-starts fresh on subsequent locks

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 04:46:46 +00:00
Marc Seitz d43d21bced fix: notion icon and page size width 2026-01-24 17:45:06 +13:00
Marc SeitzandGitHub 3d6d325770 Merge pull request #2008 from mfts/cursor/subscription-pause-timing-c0f3
Subscription pause timing
2026-01-24 17:43:03 +13:00
Cursor Agentandmarcftone e378cb6138 Update notice to specifically mention Microsoft/Outlook outage
- Changed generic message to specifically mention Microsoft outage
- Explicitly calls out Outlook and Microsoft email accounts

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 04:29:28 +00:00
Cursor Agentandmarcftone a981121faf Update email delivery notice to use Papermark orange color scheme
- Changed from red to orange colors to match Papermark brand
- border-orange-200, bg-orange-50 for container
- text-orange-800 for body text
- text-orange-600 with hover:text-orange-700 for email link

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 04:13:47 +00:00
Cursor Agentandmarcftone aa9f4db408 Fix Notion table overflow on mobile devices
- Add horizontal scrolling to Notion tables (.notion-simple-table, .notion-table)
- Add overflow handling for collection/database tables
- Prevent page-level horizontal overflow while allowing tables to scroll
- Add mobile-specific styles for proper table containment
- Use -webkit-overflow-scrolling: touch for smooth scrolling on iOS

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 03:59:02 +00:00
Cursor Agentandmarcftone c1cea5e9f6 Add delayed email delivery notice on verification page
- Added new state variable to track notice visibility
- Added useEffect that shows the notice after 5 seconds delay
- Notice only appears when user is waiting for verification email (emailLocked)
- Styled notice with red border/background similar to warning design
- Includes instructions to check spam/junk folders
- References system@papermark.com as allowed sender

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-24 03:48:19 +00:00
Marc SeitzandGitHub 2a389f885d Merge pull request #2007 from mfts/feat/email-code
feat: add email code
2026-01-24 10:14:36 +13:00
Cursor Agentandmarcftone a04db6625c fix: use 3 calendar months instead of 90 days for subscription pause
The pause_ends_at was being calculated as pauseStartsAt + 90 days, which
doesn't properly align with billing cycles. For example, Oct 25 + 90 days =
Jan 23, but 3 calendar months from Oct 25 = Jan 25.

This caused the pause_ends_at to be 2-3 days before the actual subscription
billing cycle end date, which is confusing and incorrect.

Changed to use setMonth(getMonth() + 3) to properly calculate 3 calendar
months, ensuring the pause end date aligns with the subscription's billing
cycle.

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-23 09:38:46 +00:00
Marc Seitz 6812fdd48a fix: Validate trimmed inputs and enforce code length server‑side. 2026-01-23 15:50:36 +13:00
Marc Seitz cbbc8410ba fix: Expired-state branch likely unreachable with current API status. 2026-01-23 15:48:42 +13:00
Marc Seitz 52500a427d fix: handle callbackurl 2026-01-23 10:33:50 +13:00
Marc Seitz c81e8a2ed3 fix: race condition 2026-01-23 10:32:37 +13:00
Marc Seitz 5acd71b499 feat: send only verification code 2026-01-23 10:03:19 +13:00
Marc Seitz 73444c8285 feat: add email verification route 2026-01-23 09:08:44 +13:00
Marc SeitzandGitHub 915df35378 Merge pull request #2003 from mfts/cursor/PM-467-magic-link-email-deliverability-c7a8
Magic link email deliverability
2026-01-22 09:25:51 +13:00
Marc Seitz 7ce9bcfb43 feat: update verification template 2026-01-22 09:09:36 +13:00
Marc Seitz 5a0f25ec3a feat: add verification link 2026-01-22 08:58:05 +13:00
Cursor Agentandmarcftone 44d2af43fb refactor: Use Redis instead of database for magic link tokens
- Store magic link tokens in Redis with built-in TTL (24 hours)
- Remove MagicLinkToken Prisma model and migration
- Remove cleanup cron job (Redis handles expiration automatically)
- Export getMagicLinkData function for token lookup
- Cleaner and faster implementation using existing Redis infrastructure

Benefits:
- Faster token lookups (Redis vs database)
- Automatic expiration handling via Redis TTL
- No need for cleanup jobs
- Simpler code with less database overhead

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-21 10:33:47 +00:00
Cursor Agentandmarcftone 34dbff53c8 feat: Improve Magic Link Email Deliverability
- Create MagicLinkToken model to store verification URLs server-side
- Generate short 20-character tokens instead of long encoded URLs
- URL format changed from ~400 chars to ~60 chars (e.g., /verify?token=abc123)
- Fix URL display mismatch - button href and plaintext URL now match exactly
- Enhance email content:
  - Add personalization with user email
  - Add request timestamp
  - Add 24-hour expiration notice
  - Add security information section
  - Include physical mailing address for CAN-SPAM compliance
- Add cleanup cron job for expired magic link tokens
- Maintain backward compatibility with legacy checksum-based verification
- Add friendly expired link page with option to request new link

Fixes PM-467

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-21 10:24:46 +00:00
Marc SeitzandGitHub 7bdd6a61f6 Merge pull request #2001 from mfts/cursor/subscription-cancellation-error-4309
Subscription cancellation error
2026-01-21 18:26:45 +13:00
Marc SeitzandGitHub a445d082be Merge pull request #2000 from mfts/fix/hidden
fix: hidden button
2026-01-21 18:17:39 +13:00
Marc Seitz 46bf92c5e9 fix: hidden button 2026-01-21 18:04:53 +13:00
Cursor Agentandmarcftone 283b0dd7a9 Fix subscription cancellation error when no discount exists
The cancel-route.ts was calling stripe.subscriptions.deleteDiscount()
unconditionally inside a Promise.all. This Stripe API call throws an
error if the subscription doesn't have a discount applied.

Added a .catch() handler to gracefully ignore the error when there's
no discount to delete, since this is an expected scenario for most
subscription cancellations.

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-20 20:02:11 +00:00
Marc SeitzandGitHub c09226d42e Merge pull request #1998 from mfts/cursor/hidden-documents-page-c1dd
Hidden documents page
2026-01-20 23:06:11 +13:00
Cursor Agentandmarcftone 1d4e54cb3c Use Button asChild pattern for hidden documents link
- Change from Link wrapping Button to Button with asChild prop
- Place Link inside Button as the underlying element
- Remove title prop and add aria-label for better accessibility

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-20 10:01:30 +00:00
Cursor Agentandmarcftone 6593d60509 Only show hidden documents button when there are hidden items
- Use useHiddenDocuments hook to check for hidden folders/documents
- Conditionally render the eye-off button based on hasHiddenItems

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-20 07:48:33 +00:00
Cursor Agentandmarcftone a0c89638b5 Fix teamInfo guards and Skeleton key props in hidden documents components
- Add guards for teamInfo?.currentTeam?.id in handleDeleteDocument and handleUnhideDocument
- Return early with toast error when team information is missing
- Remove duplicate key props from inner Skeleton elements in loading states

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 23:51:23 +00:00
Cursor Agentandmarcftone 491c010a17 Add /documents/hidden page to view and unhide hidden documents
- Add API endpoint /api/teams/[teamId]/documents/hidden to fetch hidden documents and folders
- Add useHiddenDocuments SWR hook
- Create HiddenDocumentsList component with bulk unhide functionality
- Create HiddenDocumentCard and HiddenFolderCard components with individual unhide
- Add hidden documents page at /documents/hidden
- Add link to hidden documents page from main documents page

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 19:36:42 +00:00
Marc SeitzandGitHub 7c73a20ce9 Merge pull request #1997 from mfts/cursor/dataroom-internal-name-sidebar-292c
Dataroom internal name sidebar
2026-01-20 08:04:23 +13:00
Marc SeitzandGitHub 0358927fe3 Merge pull request #1996 from mfts/cursor/regular-pricing-and-components-c499
Regular pricing and components
2026-01-19 22:34:10 +13:00
Cursor Agentandmarcftone e35f62e563 feat: show dataroom internal name in sidebar when present
- Updated API to include internalName in simple mode response
- Updated DataroomSimple type to include internalName
- Modified sidebar to display internalName instead of name when available

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 09:29:24 +00:00
Cursor Agentandmarcftone 668349ad65 Fix UpgradePlanModal imports in sidebar components
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 09:27:41 +00:00
Cursor Agentandmarcftone d2370f2686 Comment out YearlyUpgradeBanner from app layout
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 05:35:17 +00:00
Cursor Agentandmarcftone d2ee26252c Comment out 2025 Recap banner from sidebar navigation
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-19 05:22:00 +00:00
Cursor Agentandmarcftone 6d46db33fc Remove New Year's offer and revert to regular pricing
- Replace UpgradePlanModalWithDiscount with UpgradePlanModal in all components
- Update yearly-upgrade-banner.tsx to show regular yearly prices without discount
- Update all upgrade routes from /settings/upgrade-holiday-offer to /settings/upgrade
- Remove New Year's offer promotional text and 30% discount messaging

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-18 20:51:29 +00:00
Marc SeitzandGitHub 7966c70fd0 Merge pull request #1987 from mfts/cursor/PM-465-all-documents-item-visibility-7011
All documents item visibility
2026-01-16 17:46:43 +13:00
Marc Seitz 1a399aedfc Merge branch 'main' into cursor/PM-465-all-documents-item-visibility-7011 2026-01-16 17:39:44 +13:00
Marc SeitzandGitHub e3015ca590 Merge pull request #1995 from mfts/fix/billing
chore: add annual billing indicator
2026-01-16 17:14:10 +13:00
Marc Seitz 9655d6bada chore: add annual billing indicator 2026-01-16 17:01:29 +13:00
Marc SeitzandGitHub 474ea74a6f Merge pull request #1992 from mfts/cursor/dataroom-endpoint-performance-1c65
Dataroom endpoint performance
2026-01-16 16:58:59 +13:00
Marc SeitzandGitHub cb875d7d09 Merge pull request #1993 from mfts/cursor/subscription-pause-fields-07db
Subscription pause fields
2026-01-16 16:51:49 +13:00
Marc Seitz bdab9a56e6 chore: add datarooms mutations 2026-01-16 16:51:19 +13:00
Marc Seitz c562fb6c18 feat: add simple datarooms endpoint 2026-01-16 16:51:03 +13:00
Marc SeitzandGitHub 29e1cdaff9 Merge pull request #1994 from mfts/cursor/old-stripe-pause-coupon-b9d1
Old Stripe pause coupon
2026-01-16 16:40:14 +13:00
Cursor Agentandmarcftone 05c320e123 Update pause coupon ID for old Stripe account to VT9t1aRY
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-16 03:33:45 +00:00
Marc SeitzandGitHub c248fb259e Merge pull request #1990 from mfts/cursor/settings-seat-option-016a
Settings seat option
2026-01-16 15:19:42 +13:00
Cursor Agentandmarcftone e492f36216 Clear pause fields (pausedAt, pauseStartsAt, pauseEndsAt) and cancelledAt when purchasing a new plan or downgrading to free
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-16 02:05:01 +00:00
Marc SeitzandGitHub a9cd266279 Merge pull request #1986 from mfts/cursor/PM-464-data-room-internal-name-ea6c
Data room internal name
2026-01-16 14:56:53 +13:00
Cursor Agentandmarcftone a7aa1ecd35 Add 'Add Seat' button to settings people page
- Added 'Add Seat' button alongside the 'Invite' button
- Users can now proactively add more seats before exhausting their current limit
- When seat limit is exhausted, 'Invite' button becomes disabled with a tooltip

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-15 01:41:14 +00:00
Cursor Agentandmarcftone 65059f4562 Optimize datarooms list endpoint
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-15 01:26:15 +00:00
Cursor Agent be325c712f chore: Update internal name placeholder to be more universal
Changed from 'Friends & Family Round' to examples that apply to
fundraising, M&A, and other deal types.
2026-01-15 01:15:15 +00:00
Cursor Agent b322c4b313 refactor: Remove internal name from add dataroom modal
Keep internal name configuration only in dataroom settings to
simplify the initial creation flow.
2026-01-14 19:01:01 +00:00
Marc SeitzandGitHub 93215c8111 Merge pull request #1989 from mfts/cursor/datarooms-api-duration-6f89 2026-01-14 15:09:10 +13:00
Cursor Agentandmarcftone fbe1241175 Increase maxDuration for datarooms API endpoint to 180 seconds
This allows the GET /api/teams/[teamId]/datarooms endpoint to run
for up to 180 seconds before timing out, accommodating larger
dataroom listings.

Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-14 02:02:44 +00:00
Marc SeitzandGitHub fa5f0ff7fa Merge pull request #1988 from mfts/cursor/notion-toc-scrolling-issue-5cfa
Notion TOC scrolling issue
2026-01-14 10:40:22 +13:00
Cursor Agent 890718bcb5 Fix Notion TOC scrolling by obfuscating anchor hrefs
When the obfuscateNotionIds function changes element IDs from Notion
UUIDs to obfuscated IDs (block-0, block-1, etc.), the table of contents
anchor links were still pointing to the original UUIDs. This caused
clicking on TOC items to not scroll to the corresponding sections.

This fix updates the anchor href attributes to use the same obfuscated
IDs, ensuring TOC navigation works correctly after ID obfuscation.
2026-01-13 20:33:13 +00:00
Cursor Agent f8e79fb747 feat(all-documents): Add hide from All Documents feature
This feature allows users to hide folders and documents from the All Documents
view without deleting them:

- Add hiddenInAllDocuments field to Document and Folder models
- Create API endpoints for hiding documents (/documents/hide) and folders
  (/folders/hide)
- Update documents and folders APIs to exclude hidden items from All Documents
  view
- Add "Hide from All Documents" action to document and folder dropdown menus
- Add bulk hide action to multi-select action panel
- Cascade hiding: when a folder is hidden, all subfolders and documents within
  are also hidden

This is a non-destructive feature that only affects visibility in the All
Documents section. Hidden items remain accessible via Data Rooms and are not
deleted.

Resolves PM-465
2026-01-13 19:59:33 +00:00
Cursor Agent 787d80ac47 feat: Add internal name/alias field to Data Rooms
This feature allows users to set a private, internal name for Data Rooms
that is only visible to the owner. This helps distinguish between multiple
data rooms that share the same public-facing name.

Changes:
- Add internalName field to Dataroom schema
- Create database migration for the new field
- Update API endpoints to support creating/updating internal names
- Search now includes internal names
- Update add-dataroom-modal with internal name input
- Display internal name in dataroom cards and headers
- Add internal name form in settings page
2026-01-13 00:37:46 +00:00
Marc SeitzandGitHub 1ba44c287a Merge pull request #1985 from mfts/cursor/backend-otp-resend-rate-limit-1cc9
Backend otp resend rate limit
2026-01-10 18:21:58 +13:00
Cursor Agentandmarcftone 3eae82f830 Add email-based rate limiting to OTP requests
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-08 19:01:27 +00:00
Marc SeitzandGitHub ecc00a8dbe Merge pull request #1984 from mfts/chore/update-deps
chore: cleanup
2026-01-09 07:52:02 +13:00
Marc Seitz 4c1fdc8a5f chore: cleanup 2026-01-07 21:28:10 +13:00
Marc Seitz f5b69f9f79 fix: serialize dataroom document 2026-01-07 21:26:32 +13:00
Marc Seitz d506d2319a fix: remove /home 2026-01-07 21:26:21 +13:00
Marc SeitzandGitHub b1fd7c7ca3 Merge pull request #1983 from mfts/chore/update-deps
feat: add root redirect
2026-01-07 15:56:31 +13:00
Marc Seitz db78af011b feat: add root redirect 2026-01-07 15:50:53 +13:00
Marc SeitzandGitHub da3fbb9d9e Merge pull request #1980 from mfts/cursor/dashboard-banner-removal-fee0
Dashboard banner removal
2026-01-07 12:02:45 +13:00
Marc SeitzandGitHub e95baf78dd Merge pull request #1982 from mfts/cursor/dataroom-generation-paused-message-6826
Dataroom generation paused message
2026-01-06 11:02:33 +13:00
Marc SeitzandGitHub 4253b2a6e8 Merge pull request #1981 from mfts/cursor/dataroom-viewer-text-truncation-693d
Dataroom viewer text truncation
2026-01-06 10:50:37 +13:00
Cursor Agentandmarcftone fd9628565d feat: Prevent dataroom creation for paused teams
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-05 21:44:39 +00:00
Cursor Agentandmarcftone 3f133f8728 Refactor: Improve cursor and text alignment in cards
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-05 21:40:24 +00:00
Marc SeitzandGitHub d1127ecf55 Merge pull request #1979 from mfts/chore/update-deps
chore: update dependencies
2026-01-06 10:39:19 +13:00
Cursor Agentandmarcftone 5e4523d413 Remove YearlyRecapBanner from dashboard page
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-05 21:34:07 +00:00
Marc Seitz de9190035e chore: update dependencies 2026-01-06 10:16:12 +13:00
Marc SeitzandGitHub da5cb8aff1 Merge pull request #1978 from mfts/cursor/new-year-promotion-update-ae09
New year promotion update
2026-01-06 10:09:40 +13:00
Cursor Agentandmarcftone e2357afb55 Fix: Use apostrophe for New Year's in billing components
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-05 21:04:18 +00:00
Cursor Agentandmarcftone 49a94c8549 Update offer text and dates to New Year's promotion
Co-authored-by: marcftone <marcftone@gmail.com>
2026-01-05 20:59:11 +00:00
Marc SeitzandGitHub ee1f3b65bc Merge pull request #1977 from mfts/fix/pause
fix: stop pausing retroactively
2026-01-01 10:19:18 +01:00
Marc SeitzandGitHub 1aeb2cb28f Merge pull request #1975 from mfts/cursor/PM-462-analytics-timezone-offset-fix-498c
Analytics timezone offset fix
2026-01-01 10:02:40 +01:00
Marc Seitz dba9850ad9 fix: stop pausing retroactively 2026-01-01 10:02:31 +01:00
Marc Seitz 2ffb20ec94 fix: truncate query properly 2026-01-01 09:52:00 +01:00
Marc Seitz d020ac53a1 feat: timezone validation 2026-01-01 09:50:03 +01:00
Marc SeitzandGitHub 6a0702cd23 Merge pull request #1974 from mfts/cursor/PM-460-preview-eyeball-click-bug-54cb
Preview eyeball click bug
2026-01-01 09:34:32 +01:00
Cursor Agentandmarcftone 82f5ac83df feat: Add timezone setting for team analytics
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-30 10:08:29 +00:00
Cursor Agentandmarcftone d2abbeb886 Fix: Prevent event propagation in preview buttons
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-30 10:00:21 +00:00
Marc SeitzandGitHub f55b6aabd3 Merge pull request #1973 from mfts/cursor/premium-vs-plus-features-f9ca
Premium vs plus features
2025-12-29 21:23:26 +01:00
Cursor Agentandmarcftone 6ed3a8f4e7 feat: Add datarooms-premium plan and update related logic
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-29 17:56:26 +00:00
Marc SeitzandGitHub fb2c8d3b24 Merge pull request #1972 from mfts/fix/ai
feat(ee): expand ai capabilities
2025-12-29 15:29:42 +01:00
Marc Seitz a0286e1765 fix: don't allow filtereddocumentids if not a dataroom chat 2025-12-29 15:19:59 +01:00
Marc Seitz 9071aae4c2 fix: user selected documents don't intersect with available docs 2025-12-29 15:10:33 +01:00
Marc Seitz d581989f03 chore: remove dead code 2025-12-29 14:19:12 +01:00
Marc Seitz 6de28a6447 refactor: remove assistantEnabled variable 2025-12-29 14:18:02 +01:00
Marc Seitz a8d6b2f417 chore: remove unused function 2025-12-29 14:12:51 +01:00
Marc Seitz ea9df7f611 fix: add better warning 2025-12-29 14:12:43 +01:00
Marc Seitz 1fc1dda682 chore: cleanup 2025-12-29 14:12:36 +01:00
Marc Seitz 1d977d2290 fix: Add authorization check to verify the user has access to the run 2025-12-29 14:08:55 +01:00
Marc Seitz 7e5b416546 chore: fetch data 2025-12-27 23:05:26 +01:00
Marc Seitz 643320b860 feat(ee): add selecting documents to chat 2025-12-27 23:05:18 +01:00
Marc Seitz 277889b745 feat: add indexing status 2025-12-27 23:04:44 +01:00
Marc Seitz 9d68da1284 feat(ee): add ai processing for various document types 2025-12-27 23:03:55 +01:00
Marc Seitz 7c8ff05a22 chore: hide annotations 2025-12-27 17:43:56 +01:00
Marc Seitz c2d4b5615f chore: upgrade deps 2025-12-27 17:43:46 +01:00
Marc SeitzandGitHub 5db161a1cd Merge pull request #1971 from mfts/feat/resend
feat: add user to segment
2025-12-27 16:49:10 +01:00
Marc Seitz a19cdea265 feat: add user to segment 2025-12-27 16:35:17 +01:00
Marc SeitzandGitHub 601e60b7a6 Merge pull request #1970 from mfts/feat/resend
feat: update email sending and newsletter subscription
2025-12-27 15:22:02 +01:00
Marc Seitz 6b47cdc8f5 chore: cleanup 2025-12-27 15:01:54 +01:00
Marc Seitz 8df1e43ed6 chore: cleanup 2025-12-27 15:01:45 +01:00
Marc Seitz f481fb2e65 refactor: cleanup 2025-12-27 15:01:41 +01:00
Marc Seitz da0ad391d2 fix: lint 2025-12-27 14:54:27 +01:00
Marc Seitz 97dca7cc55 feat: update welcome email 2025-12-27 14:45:17 +01:00
Marc Seitz 3c8781af96 feat: add subscribe toggle 2025-12-27 14:14:46 +01:00
Marc Seitz 660e025647 feat: update welcome email 2025-12-27 13:49:18 +01:00
Marc Seitz 53d09ef437 feat: add resend subscriptions 2025-12-27 12:36:39 +01:00
Marc SeitzandGitHub 8698f710b6 Merge pull request #1950 from mfts/links-as-documents
feat: link as a file
2025-12-22 16:07:02 +01:00
Marc Seitz 4e87dd0aac fix: add link 2025-12-22 15:00:20 +01:00
Marc Seitz 91407a5d94 feat: add additional checks 2025-12-22 14:52:35 +01:00
Marc Seitz a0be39e551 refactor: move logic to process-document 2025-12-22 14:52:27 +01:00
Marc Seitz 77e15d7287 feat: add noopener 2025-12-22 14:52:14 +01:00
Marc Seitz 9c575472eb feat: add tracking to link adding 2025-12-22 14:43:25 +01:00
Marc Seitz b16decab6b feat: add link indicator and additional validation 2025-12-22 14:30:15 +01:00
Marc Seitz 006c6e6826 feat: show link clicks on visitor state 2025-12-21 17:04:44 +01:00
Marc Seitz f4812fc8a8 chore: cleanup link preview 2025-12-21 17:04:35 +01:00
Marc Seitz 08e045ed11 Merge branch 'main' into links-as-documents 2025-12-21 15:52:33 +01:00
Marc SeitzandGitHub 6169d297a2 Merge pull request #1968 from mfts/yearly-switch
feat:yearly discount offer
2025-12-20 15:34:28 +01:00
Marc Seitz 3a945f1c58 chore: remove unused function 2025-12-20 15:11:11 +01:00
Marc Seitz b4681866ae fix: modal 2025-12-20 15:10:00 +01:00
Marc Seitz 12eba5d39b fix: cleanup duplicate 2025-12-20 15:08:45 +01:00
Marc Seitz 9b1c4df214 fix: noopener 2025-12-20 14:54:33 +01:00
Marc Seitz f24bd26ad3 fix 2025-12-20 14:53:23 +01:00
Marc Seitz f6a09b1647 fix: css 2025-12-20 14:51:32 +01:00
Marc Seitz eb1d325021 fix 2025-12-20 14:51:01 +01:00
Marc Seitz 9927f7c0ac fix 2025-12-20 14:49:13 +01:00
Marc Seitz c5373fc903 fix 2025-12-20 14:48:58 +01:00
Marc Seitz 526c036e61 Merge branch 'main' into yearly-switch 2025-12-20 14:29:49 +01:00
Marc SeitzandGitHub 921b237b86 Merge pull request #1965 from mfts/feat/pause-limits
feat: improve pause limits
2025-12-20 14:15:00 +01:00
Marc Seitz 8e2d06669d fix 2025-12-20 13:55:31 +01:00
Marc Seitz 2e6a4fe356 fix 2025-12-20 13:53:51 +01:00
Marc Seitz 63ce7ba619 Merge branch 'main' into feat/pause-limits 2025-12-20 13:40:54 +01:00
Marc Seitz 52c9411143 fix: remove logs 2025-12-20 13:39:04 +01:00
Marc Seitz e80b59de3a fix: effective end date 2025-12-20 13:37:09 +01:00
Marc Seitz e0bcad2cfe fix: paused empty return 2025-12-20 13:31:19 +01:00
Iuliia Shnai a209eab11f feat:updates 2025-12-19 20:25:14 +01:00
Marc SeitzandGitHub b1c5673076 Merge pull request #1967 from mfts/fix/link-domain
fix
2025-12-19 19:44:07 +01:00
Marc Seitz e17d9f2ba5 feat: scope webhook 2025-12-19 17:58:05 +01:00
Marc Seitz 08f0020049 fix: missing pauseStartsAt 2025-12-19 17:56:42 +01:00
Marc Seitz 6a42edb734 fix 2025-12-19 17:43:14 +01:00
Marc Seitz 267d75848b fix 2025-12-15 15:01:37 +01:00
Marc Seitz 804350fd27 fix 2025-12-15 14:56:55 +01:00
Marc Seitz b88dcb6055 fix 2025-12-15 14:55:51 +01:00
Marc Seitz 505a5ea1e3 feat: updates to pause 2025-12-15 14:45:24 +01:00
Marc Seitz bf292d420e feat: improve pause functionality 2025-12-15 13:27:37 +01:00
Marc Seitz 0b8d204514 Merge branch 'main' into feat/pause-limits 2025-12-15 07:34:36 +01:00
Marc Seitz 8c1a36c60b fix: reduce output 2025-12-15 07:32:00 +01:00
Marc Seitz 7f45a4d251 fix: increase maxduration 2025-12-14 20:33:17 +01:00
Marc Seitz 6d5ba6b6e3 feat: add analytics to sidebar item 2025-12-14 18:17:32 +01:00
Marc SeitzandGitHub a5658720fc Merge pull request #1964 from mfts/data-room-generation-fix
feat: fix for AI data room generation
2025-12-14 16:52:31 +01:00
Marc Seitz be934b9d98 fix: folder depth 2025-12-14 16:37:30 +01:00
Marc Seitz 5f699e33d8 chore: update deps 2025-12-14 16:37:22 +01:00
Marc Seitz 1ed2ecac19 Merge branch 'main' into data-room-generation-fix 2025-12-14 16:19:04 +01:00
Marc SeitzandGitHub fda5c8af95 Merge pull request #1952 from mfts/yearly-updates
feat:updates for year in review
2025-12-14 16:16:57 +01:00
Marc Seitz 11fc479623 fix: mobile 2025-12-14 16:08:19 +01:00
Marc Seitz e8650b23ca fxi 2025-12-14 15:46:55 +01:00
Marc Seitz 1d94f79d55 fix 2025-12-14 15:39:11 +01:00
Marc Seitz b5613e9bb1 fix: analytics 2025-12-14 15:32:29 +01:00
Marc SeitzandGitHub 1ff5097b20 Merge pull request #1963 from mfts/cursor/fix-data-room-brand-logo-1606 2025-12-11 20:53:51 +01:00
Iuliia Shnai e38e30f16c feat: fix for AI data room generation 2025-12-11 18:18:06 +01:00
Cursor Agentandmarcftone b8e82ff7f1 Feat: Add team brand fallback for dataroom links
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-11 00:15:20 +00:00
Marc SeitzandGitHub 04470ab8a3 Merge pull request #1961 from mfts/fix/load 2025-12-10 22:31:56 +01:00
Marc SeitzandGitHub 96677f95ad Merge pull request #1960 from mfts/cursor/obfuscate-notion-id-in-html-8b36 2025-12-10 22:31:21 +01:00
Marc Seitz fb749ad02b Merge branch 'cursor/obfuscate-notion-id-in-html-8b36' of github.com:mfts/papermark into cursor/obfuscate-notion-id-in-html-8b36 2025-12-10 11:05:23 +01:00
Marc Seitz 17d109b10a feat: obfuscate data-id 2025-12-10 11:05:01 +01:00
Cursor Agentandmarcftone 177d51dfb4 feat: Obfuscate data-id attributes in Notion page viewer
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-10 09:57:15 +00:00
Marc SeitzandGitHub d67f501d8e Merge pull request #1959 from mfts/fix/delete-group
fix: delete dataroom groups
2025-12-10 10:51:06 +01:00
Marc Seitz 426bc9f1fb fix: n+1 link query 2025-12-10 10:50:55 +01:00
Cursor Agentandmarcftone 898959b853 feat: Obfuscate Notion block IDs in DOM
Co-authored-by: marcftone <marcftone@gmail.com>
2025-12-10 00:33:20 +00:00
Marc Seitz 33d8a85f8e feat: add hot keys and tracking 2025-12-09 18:32:24 +01:00
Marc Seitz 4c0b8c0991 chore: clean logs 2025-12-09 18:31:50 +01:00
Marc SeitzandGitHub c04212a7ef Merge pull request #1954 from mfts/feat/clay
feat: add logging for trials
2025-12-09 17:53:54 +01:00
Marc Seitz 0f9ff62934 fix: delete dataroom groups 2025-12-09 17:53:10 +01:00
Marc Seitz 5253362286 fix: improve year-in-review 2025-12-09 17:51:26 +01:00
Marc SeitzandGitHub 911f60dbfd Merge pull request #1958 from mfts/fix/requests
fix: reduce tinybird requests from n to 1
2025-12-09 11:11:00 +01:00
Marc Seitz 473463f946 Merge branch 'main' into yearly-updates 2025-12-09 11:03:43 +01:00
Marc Seitz a5bdadbe9d fix: reduce tinybird requests from n to 1 2025-12-09 10:59:46 +01:00
Marc SeitzandGitHub 96365b750f Merge pull request #1957 from mfts/fix/requests
fix: errored requests
2025-12-09 10:06:06 +01:00
Marc Seitz 4f2b68b87a fix 2025-12-09 09:51:30 +01:00
Marc Seitz cb9c53b29c fix 2025-12-09 09:33:15 +01:00
Marc Seitz 332db018b2 fix: errored requests 2025-12-09 09:20:42 +01:00
Marc SeitzandGitHub 381a2ca4b9 Merge pull request #1953 from mfts/feat/agentz
feat(ee): add chat agent
2025-12-08 22:57:29 +01:00
Marc Seitz 6a0b3854a1 Merge branch 'main' into feat/agentz 2025-12-08 22:47:59 +01:00
Marc Seitz 04f6b48c07 fix: remove old code 2025-12-08 22:37:40 +01:00
Marc Seitz 7f4beb1ef9 fix: remove node-fetch 2025-12-08 22:37:33 +01:00
Marc Seitz 170c685ee7 fix: filters and auth 2025-12-08 22:19:54 +01:00
Marc Seitz cf292e4ca0 chore: update deps 2025-12-08 22:19:39 +01:00
Marc Seitz f05046ee19 fix: constants 2025-12-08 21:58:59 +01:00
Marc Seitz c48b6480b7 fix: video processing 2025-12-08 21:57:50 +01:00
Marc Seitz 33c64d0e61 chore: bump node version 2025-12-08 21:57:42 +01:00
Marc Seitz f410aab14d feat: turn off autocapture 2025-12-08 21:57:30 +01:00
Marc Seitz e3a48714b1 chore: update package.json 2025-12-08 21:38:19 +01:00
Marc Seitz 82d42cf0ae fix: add missing feature flag 2025-12-08 21:29:37 +01:00
Marc Seitz 5d3680decf fix: searchparam zod coercion 2025-12-08 21:26:21 +01:00
Marc Seitz e1a172eb93 chore: typo 2025-12-08 21:26:06 +01:00
Marc Seitz 02dde44c65 fix: update type 2025-12-08 21:23:26 +01:00
Marc Seitz 28ee768154 chore: remove file 2025-12-08 21:23:21 +01:00
Marc Seitz e1bca40e73 fix: better type for responseId 2025-12-08 21:17:59 +01:00
Marc Seitz ed13963d02 fix: return recent history 2025-12-08 21:14:33 +01:00
Marc Seitz 326a06e6b0 fix: memory leak 2025-12-08 19:10:25 +01:00
Marc Seitz 1fd21ad9da fix: custom css property reference 2025-12-08 19:08:10 +01:00
Marc Seitz e17ece301a fix: missing persistence of fileId 2025-12-08 19:04:50 +01:00
Marc Seitz ba64f84182 fix: wrong variable used 2025-12-08 19:03:46 +01:00
Marc Seitz d11a44ddeb fix: inverted logic 2025-12-08 19:03:15 +01:00
Marc Seitz d3803daf7b feat: add logging for trials 2025-12-08 18:57:41 +01:00
Iuliia Shnai bf1c6b8bd5 feat:updates 2025-12-08 17:52:05 +01:00
Marc Seitz 911002818c chore: remove deprecated chat 2025-12-08 17:23:21 +01:00
Marc Seitz 889056b321 feat(ee): add agent chat 2025-12-08 17:23:07 +01:00
Marc Seitz ad502eb732 chore: remove deprecated chat 2025-12-08 17:00:11 +01:00
Marc Seitz 02482486be chore: remove plausible 2025-12-08 16:59:01 +01:00
Iuliia Shnai 85f6992afa feat:updates for year in review 2025-12-07 19:39:40 +01:00
Marc SeitzandGitHub ea6b5e5a67 Merge pull request #1946 from mfts/ai-data-rooms
data-room-update
2025-12-03 15:11:15 +01:00
Marc Seitz cbcf9cabe1 Merge branch 'main' into ai-data-rooms 2025-12-03 14:58:53 +01:00
Marc Seitz e78f164063 fix 2025-12-03 14:57:01 +01:00
Marc Seitz 820eac3197 refactor: add prompts file 2025-12-03 14:47:26 +01:00
Marc Seitz d5cc515025 chore: remove file 2025-12-03 14:47:01 +01:00
Iuliia Shnai 5ef9b92163 feat: link as a file 2025-12-01 22:08:44 +01:00
Marc SeitzandGitHub 199cebfc1f Merge pull request #1949 from mfts/fix/qa-on-group
fix: conversation not saving on link create
2025-12-01 11:05:11 +01:00
Marc Seitz c945bd3959 fix: add missing variable for conversation 2025-12-01 10:57:40 +01:00
Marc Seitz fea0e76a40 fix: typo 2025-12-01 10:57:26 +01:00
Marc Seitz 04fb2499a2 fix 2025-12-01 10:52:13 +01:00
Marc SeitzandGitHub 8553981b97 Merge pull request #1948 from mfts/chore/conversion
chore: update conversion and packages
2025-11-30 14:56:42 +01:00
Marc Seitz a877f8880e fix 2025-11-30 14:48:32 +01:00
Marc Seitz 1d51ba955d chore: update packages 2025-11-30 14:44:17 +01:00
Marc Seitz 373e05caa7 fix: scale to 2.9 to avoid a artifact issue 2025-11-30 14:41:00 +01:00
Marc SeitzandGitHub 1dff5deb8e Merge pull request #1947 from mfts/chore/change
feat: add tags to datarooms
2025-11-30 13:40:31 +01:00
Marc Seitz 6624398277 fix: validate if tags exists 2025-11-30 13:27:08 +01:00
Marc Seitz 74eba7ee0e fix: add total dataroom count 2025-11-30 13:21:51 +01:00
Marc Seitz dd93efeded chore: remove status filter 2025-11-30 13:19:42 +01:00
Marc Seitz 4fb0ade585 feat: add tags to datarooms 2025-11-30 12:41:23 +01:00
Iuliia Shnai 66b646eb11 data-room 2025-11-30 00:52:11 +01:00
Marc SeitzandGitHub c67f6f0b1f Merge pull request #1945 from mfts/chore/change
chore: change subject line
2025-11-27 17:52:45 +01:00
Marc Seitz 57609e2958 chore: change subject line 2025-11-27 17:40:50 +01:00
Marc SeitzandGitHub 253d355168 Merge pull request #1944 from mfts/feat/checkbox-field
feat: add checkbox to custom form field
2025-11-27 11:45:06 +01:00
Marc Seitz 2402169622 feat: add checkbox to custom form field 2025-11-27 11:35:08 +01:00
Marc SeitzandGitHub b519e1802a Merge pull request #1942 from mfts/fix/email
feat: update email sending
2025-11-27 10:43:56 +01:00
Marc Seitz 49e81c6e61 feat: update email sending 2025-11-26 19:06:01 +01:00
Marc SeitzandGitHub 596752eb42 Merge pull request #1941 from mfts/feat/add-name
feat: add viewerName if available
2025-11-26 18:44:26 +01:00
Marc Seitz 9f14daf15e feat: add viewerName if available 2025-11-26 18:38:20 +01:00
Marc SeitzandGitHub 0d2d5a8b54 Merge pull request #1940 from mfts/fix/password
fix: encrypt preset password via api
2025-11-25 14:28:35 +01:00
Marc Seitz ecdf56622b fix: encrypt preset password via api 2025-11-25 14:21:36 +01:00
Marc SeitzandGitHub c9e7b5780e Merge pull request #1939 from mfts/feat/billing-update
feat: add invoices page
2025-11-25 14:05:59 +01:00
Marc Seitz c1edff4bc6 feat: add invoices page 2025-11-25 12:43:32 +01:00
Marc SeitzandGitHub 227709eab1 Merge pull request #1938 from mfts/cursor/schedule-yearly-renewal-email-reminder-claude-4.5-sonnet-thinking-78d7
Schedule yearly renewal email reminder
2025-11-23 22:50:09 +01:00
Marc Seitz d8afb0f5e4 fix 2025-11-23 22:21:05 +01:00
Marc Seitz 2dc5f00e3c fix 2025-11-23 22:20:10 +01:00
Marc Seitz 41d25853d2 chore: remove customer name 2025-11-23 20:42:36 +01:00
Marc Seitz 4b77499e12 fix: add return statement 2025-11-23 20:42:21 +01:00
Marc Seitz 95b82d34b9 chore: remove unreachable code 2025-11-23 20:39:11 +01:00
Marc Seitz a12900c701 chore: update dependencies 2025-11-23 20:36:17 +01:00
Marc Seitz 418a2a0225 feat: add link to billing settings 2025-11-23 20:36:02 +01:00
Marc Seitz 726ee0a463 feat: update invoice upcoming 2025-11-23 20:20:20 +01:00
Marc Seitz 502b48e05d fix: return 200 when team was deleted 2025-11-23 20:19:51 +01:00
Marc Seitz 09ea8cbfa3 refactor 2025-11-23 20:19:38 +01:00
Cursor Agentandmarcftone 1446601c84 feat: Add subscription renewal reminder email
Co-authored-by: marcftone <marcftone@gmail.com>
2025-11-23 09:37:27 +00:00
Marc SeitzandGitHub b79cf8ae8e Merge pull request #1937 from mfts/new-pricing-plan
fix: ratelimit
2025-11-20 22:05:56 +01:00
Marc Seitz 578f9a1b12 fix: ratelimit 2025-11-20 21:44:04 +01:00
Marc SeitzandGitHub e5bc34c909 Merge pull request #1935 from mfts/new-pricing-plan
New pricing plan
2025-11-20 20:51:12 +01:00
Marc Seitz adf625b5a5 fix: button color 2025-11-20 20:42:33 +01:00
Marc SeitzandGitHub f4280e2fa4 Merge pull request #1936 from mfts/fixes-2
fix: dataroom index
2025-11-17 22:52:12 +01:00
Marc Seitz 961dff3c70 fix: dataroom index 2025-11-17 22:36:14 +01:00
Iuliia Shnai 44a0206065 feat:updates 2025-11-17 21:13:48 +01:00
Marc SeitzandGitHub b2201b34f6 Merge pull request #1934 from mfts/fixes
fix: various fixes
2025-11-17 21:05:11 +01:00
Iuliia Shnai bb077a7cb4 feat:updates 2025-11-17 21:03:48 +01:00
Iuliia Shnai e48b8ef100 feat: new plan
new plan, small fixes
2025-11-17 20:57:08 +01:00
Marc Seitz 44507a56cf fix 2025-11-17 20:10:30 +01:00
Marc Seitz 3345c98b6e feat: remove readme 2025-11-17 20:10:19 +01:00
Marc Seitz 4c1e173ed4 fix: don't require slug 2025-11-17 20:08:54 +01:00
Marc Seitz 5b497a7c9f refactor: invitations modal 2025-11-17 20:08:45 +01:00
Marc Seitz cc44802ecd feat: add rebuild index for datarooms 2025-11-17 20:01:20 +01:00
Marc Seitz 9aae2d5f93 feat: add invitedAt timestamp tooltip 2025-11-17 19:43:44 +01:00
Marc Seitz d6776d05a4 fix: link name 2025-11-17 19:42:59 +01:00
Marc SeitzandGitHub 0656512889 Merge pull request #1933 from mfts/feat/invite-by-mail
feat(ee): dataroom invitations
2025-11-17 19:05:29 +01:00
Marc Seitz ea43a6aa7f fix: undo rename 2025-11-17 18:42:37 +01:00
Marc Seitz f1320a809c chore: rename migration 2025-11-17 18:41:02 +01:00
Marc Seitz cecde87312 chore: add maxduration 2025-11-17 18:37:37 +01:00
Marc Seitz ba26b342b0 Merge branch 'main' into feat/invite-by-mail 2025-11-17 17:58:44 +01:00
Marc Seitz dcd40b30f0 feat: add dataroom home to group permissions 2025-11-17 17:57:58 +01:00
Marc Seitz 9e71a3e47b feat(ee): add dataroom invites 2025-11-17 15:38:52 +01:00
Marc SeitzandGitHub 7b355d2c4e Merge pull request #1932 from mfts/feat/workflows
feat(ee): workflows
2025-11-17 14:20:37 +01:00
Marc Seitz 10d252d6d1 fix: refactor workflow API and update link type handling
Refactored ID validation in workflow API routes for consistency and readability. Updated workflow engine to improve action result handling. Added 'WORKFLOW_LINK' to linkType enum in webhook schema. Improved type safety and code clarity in view pages, including dataroom lastUpdatedAt calculation and link prop usage.
2025-11-17 13:46:19 +01:00
Marc Seitz d3ea230dbc refactor: workflow API to require teamId for access control
All workflow-related API routes now require a teamId query parameter and validate user membership via the userTeam table. This change improves security and access control, ensuring users can only access workflows and steps belonging to their teams. Frontend components and pages have been updated to pass teamId in API requests, and the workflow creation schema no longer requires teamId in the request body.
2025-11-17 13:32:47 +01:00
Marc Seitz 8bbc95b861 fix: exclude workflow type from link sheet 2025-11-17 13:28:00 +01:00
Marc Seitz c1f96333dc fix: set slug to undefined for papermark.com domain
When the domain is 'papermark.com', the slug field is now set to undefined instead of a trimmed value. This aligns slug handling with domain-specific requirements.
2025-11-17 13:15:53 +01:00
Marc Seitz f21da73b3a fix: update code validation in AccessRequestSchema
Changed the code field validation from fixed length to a regex that enforces exactly 6 digits. This ensures the verification code consists only of digits and is 6 characters long.
2025-11-17 13:14:08 +01:00
Marc Seitz 92b5c33937 fix: update domain field to use nullish in schema
Changed the 'domain' field in CreateWorkflowRequestSchema from optional to nullish, allowing both null and undefined values to indicate papermark.com as the default.
2025-11-17 13:13:17 +01:00
Marc Seitz 6b73afdf94 fix: reset form state on dialog open or step change
Added a useEffect hook to re-populate form state when the dialog is opened or the step changes, preventing stale values in name, targetLinkId, and allowListInput fields.
2025-11-17 13:12:13 +01:00
Marc Seitz ecc3a4de93 fix: use validated data for workflow step updates and creation
Refactors PATCH and POST handlers to use validated conditions and actions for workflow step updates and creation, ensuring enrichment and mutation persist. Also improves Zod error formatting in validation utilities for better readability.
2025-11-17 13:10:58 +01:00
Marc Seitz ad57a7d36f fix: export 2025-11-17 13:07:40 +01:00
Marc Seitz c6eeda232f fix: parse input 2025-11-17 13:07:35 +01:00
Marc Seitz a84729b3cd fix: safe parse params 2025-11-17 13:07:18 +01:00
Marc Seitz 51b3684b73 fix: remove logs 2025-11-17 13:06:38 +01:00
Marc Seitz 6aeb79c211 feat(ee): add workflows 2025-11-16 19:06:53 +01:00
Marc Seitz 5faba225e6 feat(ui): add single-select with search option 2025-11-16 19:01:50 +01:00
Marc Seitz 4f94e6d6b8 feat: add new link-based session 2025-11-16 18:46:53 +01:00
Marc SeitzandGitHub 5f9899a142 Merge pull request #1931 from mfts/fix/download
chore: extend maxduration for download function
2025-11-12 18:30:24 +04:00
Marc Seitz ed45589118 chore: extend maxduration for download function 2025-11-12 18:00:46 +04:00
Marc SeitzandGitHub d0918ceaff Merge pull request #1929 from mfts/feat/track-bulk-downloads
feat: improve bulk download tracking
2025-11-06 20:52:40 +04:00
Marc Seitz 69c9cab22c feat: add timestamp tooltip
thanks to dub for the incredible work!
2025-11-06 18:54:58 +04:00
Marc SeitzandGitHub b98f2e368c Merge pull request #1928 from mfts/cursor/add-per-document-advanced-mode-toggle-f733
Add per-document advanced mode toggle
2025-11-06 18:47:01 +04:00
Marc Seitz 51027a2d92 feat: track folder download documents 2025-11-06 18:25:03 +04:00
Marc Seitz f0496b0419 fix: only update the documentversion numpages if the document is enabled 2025-11-06 16:02:25 +04:00
Marc Seitz cb466d518a refactor: make request faster 2025-11-06 15:46:46 +04:00
Marc Seitz 60fcd1a1ae chore: remove plausible 2025-11-06 15:46:35 +04:00
Cursor Agentandmarcftone ad624c1027 Refactor: Enable/disable advanced Excel mode and track events
Co-authored-by: marcftone <marcftone@gmail.com>
2025-11-06 09:30:17 +00:00
Marc SeitzandGitHub 1acfcc0641 Merge pull request #1927 from mfts/cursor/add-presentation-full-screen-button-59bd
Add presentation full screen button
2025-11-06 00:09:35 +04:00
Cursor Agentandmarcftone beff86b745 feat: Add fullscreen functionality to viewers
Adds a fullscreen toggle and keyboard shortcut to image and page viewers.

Co-authored-by: marcftone <marcftone@gmail.com>
2025-11-05 19:49:09 +00:00
Marc SeitzandGitHub 99c2b4249f Merge pull request #1926 from mfts/feat/download-type-tracking
feat: add support for keynote files
2025-11-05 14:31:34 +01:00
Marc Seitz 45e138f4c9 feat: add support for keynote files 2025-11-05 17:05:02 +04:00
Marc SeitzandGitHub 8988cc489f Merge pull request #1925 from mfts/feat/download-type-tracking
fix(api): respect enableNotification preset setting
2025-11-05 13:12:40 +01:00
Marc Seitz c7780a0f18 fix(api): respect enableNotification preset setting 2025-11-05 15:51:04 +04:00
Marc SeitzandGitHub ec19de5d90 Merge pull request #1923 from mfts/fix/watermark
fix: watermark download
2025-11-02 21:07:16 +01:00
Marc Seitz 8032f898e8 fix: moving folders to subfolders didn't update the path properly 2025-11-02 20:59:50 +01:00
Marc Seitz 8ad900accf fix: download regularly if no watermark config 2025-11-02 20:54:25 +01:00
Marc Seitz 252266ba4b fix: validation of url 2025-11-02 20:52:18 +01:00
Marc Seitz b30fe03b79 feat: update watermark management 2025-11-02 13:15:44 +01:00
Marc Seitz 85a05132b1 feat: add toast info when downloading individual doc 2025-11-02 13:14:01 +01:00
Marc SeitzandGitHub 8cb033178f Merge pull request #1922 from mfts/feat/delete-doc-warning
feat: improvements
2025-11-01 14:48:23 +01:00
Marc Seitz 07125420a6 fix: return json on errors 2025-11-01 14:37:48 +01:00
Marc Seitz 09525ad5f2 feat(api): add dataroomFolderId and folders for dataroom 2025-11-01 14:27:12 +01:00
Marc Seitz 4b44d8eaad fix: dataroom.create endpoint 2025-11-01 14:14:58 +01:00
Marc Seitz 6912c409fc feat: add column spacing 2025-11-01 14:07:02 +01:00
Marc Seitz d013a2ff77 fix: notion icon positioning and arrow on page links 2025-11-01 14:03:49 +01:00
Marc Seitz d5569d6a1f feat: add permissions for admin/manager to delete docs 2025-11-01 13:35:27 +01:00
Marc SeitzandGitHub 873c74d078 Merge pull request #1921 from mfts/iuliia-links-fixes
feat: fixes
2025-11-01 13:09:32 +01:00
Marc Seitz 715c702139 chore: update coupons 2025-10-31 22:12:27 +01:00
Marc Seitz 5454779c3c chore: update text 2025-10-31 22:12:22 +01:00
Marc Seitz 652327454a fix: show existing welcome message for links 2025-10-31 22:10:56 +01:00
Marc Seitz 7d32b538e4 chore: remove description 2025-10-31 22:09:55 +01:00
Marc Seitz c58e7e5223 refactor: reorder custom fields section 2025-10-31 22:08:56 +01:00
Marc Seitz c0ef51ea7a feat: fixes 2025-10-31 21:03:49 +01:00
Marc SeitzandGitHub e3986a560f Merge pull request #1920 from mfts/iuliia-fixes
Iuliia fixes
2025-10-30 19:56:15 +01:00
Marc Seitz c04d31c474 fix: add default banner on global branding 2025-10-30 19:35:37 +01:00
Marc Seitz 0f0fcbdb6b fix: branding 2025-10-30 19:32:10 +01:00
Marc Seitz dbc4acdac7 feat: global branding fixes 2025-10-27 23:10:39 +01:00
Marc Seitz 3a0e2d9677 feat: fixes pro banner, branding
pro banner show now business and data rooms plan, and branding screen fixes
2025-10-27 20:55:19 +01:00
Marc SeitzandGitHub e71bad4b6c Merge pull request #1919 from mfts/feat/increase-duration
fix
2025-10-27 14:41:18 +01:00
Marc Seitz a86a5f4cfc fix 2025-10-27 14:11:59 +01:00
Marc Seitz a010662461 feat: small fixes
1. visits to views
2. name on dataroom from template
3. trial days
4. reactions to %. of views
2025-10-25 17:51:06 +02:00
Marc SeitzandGitHub 10b19a2cec Merge pull request #1916 from mfts/feat/add-redirect
feat: add redirect
2025-10-24 17:32:09 +02:00
Marc Seitz a50d41407d Update domain.ts 2025-10-24 17:26:16 +02:00
Marc SeitzandGitHub b77c555249 Merge pull request #1915 from mfts/iuliia-dataroom
feat: updates to dataroom templates
2025-10-23 23:01:30 +02:00
Marc Seitz cb8e24c87f feat: add prisma transaction 2025-10-23 22:53:46 +02:00
Marc Seitz fb56b57527 chore: cleanup 2025-10-23 22:53:38 +02:00
Marc Seitz 2548ace748 feat: add zod validation 2025-10-23 21:48:54 +02:00
Marc Seitz 9097b97929 fix: typo 2025-10-23 21:48:49 +02:00
Marc Seitz 5984dd4d6a feat: add zod validation 2025-10-23 21:41:31 +02:00
Marc Seitz 76abad0fe3 refactor: dataroom templates page 2025-10-23 21:40:34 +02:00
Marc Seitz 2bf735296d feat(ee): add templates 2025-10-23 21:26:50 +02:00
Marc Seitz b1fd391b44 feat: change to .com 2025-10-23 21:24:29 +02:00
Marc Seitz 5b5462e3e7 feat: templates added 2025-10-23 13:14:06 +02:00
Marc Seitz 42e6acfc49 feat: data room from templates 2025-10-22 23:12:48 +02:00
Marc SeitzandGitHub 4d2e19d4ed Merge pull request #1914 from mfts/cursor/allow-custom-fields-on-data-room-subscription-7f5b
Allow custom fields on data room subscription
2025-10-22 18:10:16 +02:00
Cursor Agentandmarcftone 1594dc9342 Increase custom field limit for Datarooms plans
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-22 14:21:24 +00:00
Marc SeitzandGitHub ddf3f99789 Merge pull request #1913 from mfts/feat/email-redirect
feat: update the checking email
2025-10-22 14:29:15 +02:00
Marc Seitz 288ac0ca9e feat: update the checking email 2025-10-22 14:12:04 +02:00
Marc SeitzandGitHub 5bb4b7b769 Merge pull request #1912 from mfts/feat/email-redirect
feat: transition email domain
2025-10-22 13:41:58 +02:00
Marc Seitz 55d3158033 feat: transition email domain 2025-10-22 13:24:40 +02:00
Marc SeitzandGitHub 02e8a9e1f8 Merge pull request #1906 from mfts/cursor/implement-link-deletion-with-warning-b8d0
Implement link deletion with warning
2025-10-22 13:23:50 +02:00
Marc Seitz e83574ca24 chore: change subject text on login link 2025-10-22 13:17:56 +02:00
Marc Seitz 97eea0dc74 chore: change to papermark.com 2025-10-22 13:17:23 +02:00
Marc Seitz e40d75b24e fix add redirect 2025-10-22 13:01:29 +02:00
Marc Seitz 3f3f117349 fix 2025-10-22 12:29:38 +02:00
Marc Seitz a6fd026e5f fix: async promise executer 2025-10-21 23:21:20 +02:00
Marc Seitz 548c463d18 feat: exclude deleted links 2025-10-21 23:15:46 +02:00
Marc Seitz 0354acb32a feat: add document api link 2025-10-21 22:51:19 +02:00
Marc Seitz 82ceb78d20 feat: add deleted links 2025-10-21 22:44:03 +02:00
Marc SeitzandGitHub b483f7553e Merge pull request #1911 from mfts/cursor/add-agreement-text-field-character-counter-and-increase-limit-3f87
feat: add agreement text field character counter and increase limit 3f87
2025-10-21 21:03:40 +02:00
Marc Seitz 8050cb3e9e feat: make checkbox color dynamic 2025-10-21 20:07:50 +02:00
Marc Seitz fcd1a9dc04 chore: prettier 2025-10-21 20:07:25 +02:00
Marc Seitz 44e966f6dc feat: improve sanitzation to custom length 2025-10-21 20:07:19 +02:00
Cursor Agentandmarcftone 77613fa21e feat: Add character limit and counter for agreement content
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-20 07:58:31 +00:00
Marc SeitzandGitHub 189242e3d1 Merge pull request #1908 from mfts/cursor/add-option-to-hide-branding-banner-5b84
Add option to hide branding banner
2025-10-19 23:17:19 +02:00
Marc Seitz 3ffb52c4cb fix: branding 2025-10-19 22:44:23 +02:00
Cursor Agentandmarcftone c239fb0eed Refactor: Simplify banner logic to use only "no-banner"
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-19 20:10:37 +00:00
Marc SeitzandGitHub 35debf0592 Merge pull request #1909 from mfts/fix/zoom
fix: zoom not centered
2025-10-19 15:49:51 +02:00
Marc Seitz 0a1cd3fad3 fix: zoom not centered 2025-10-19 15:08:24 +02:00
Cursor Agentandmarcftone cd27f3043c feat: Allow hiding dataroom banner via branding
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-19 09:26:38 +00:00
Cursor Agentandmarcftone 8083b65792 feat: Add delete link functionality and modal
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-18 06:39:58 +00:00
Marc SeitzandGitHub 0ba04d51b9 Merge pull request #1904 from mfts/cursor/add-banner-toggle-to-presets-4eb8
Add banner toggle to presets
2025-10-17 21:20:46 +02:00
Cursor Agentandmarcftone 202f587cb9 feat: Add showBanner option to link presets
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-17 15:38:42 +00:00
Marc SeitzandGitHub 3ba6ca9c76 Merge pull request #1903 from mfts/iuliia/working-branch
Iuliia/working branch
2025-10-16 09:33:07 +02:00
Marc Seitz ceaa616d51 feat: sort datarooms in database 2025-10-16 09:08:52 +02:00
Marc Seitz 1af7f350a8 fix: missing dataroom 2025-10-16 09:08:41 +02:00
Marc Seitz df8669a790 chore: improvements 2025-10-16 08:46:07 +02:00
Marc Seitz ea2ece0e06 chore: improve 2025-10-16 08:37:14 +02:00
Marc SeitzandGitHub 3110d67e8e Merge pull request #1902 from mfts/cursor/apply-dataroom-accent-color-to-otp-screen-e9fa
Apply dataroom accent color to otp screen
2025-10-16 08:21:25 +02:00
Marc Seitz 76d9fc3456 feat: dataroom cover design update 2025-10-15 20:46:15 +02:00
Marc Seitz dc0865f144 feat: updates sidebar for data rooms 2025-10-15 19:18:56 +02:00
Marc Seitz e7fee43fb7 FEAT:UPDATES 2025-10-15 18:34:57 +02:00
Marc Seitz 8a740dcfb0 feat: branding design updates and triall banner updates 2025-10-15 18:30:22 +02:00
Marc Seitz 9900821e1b fix: prop context for accentcolor 2025-10-15 14:54:56 +02:00
Cursor Agentandmarcftone 27b78608fc feat: Add accent color support to email verification and OTP inputs
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-15 11:32:13 +00:00
Marc SeitzandGitHub 9bb6b39441 Merge pull request #1900 from mfts/feat/agents
fix: ratelimt limits
2025-10-13 14:50:05 +02:00
Marc Seitz e38f675e3c fix: ratelimt limits 2025-10-13 12:55:55 +02:00
Marc SeitzandGitHub 7cb94e0b3d Merge pull request #1899 from mfts/feat/only-datarooms
feat: improve team settings loading
2025-10-10 12:54:28 +02:00
Marc Seitz 8f0c41967c feat: add team paused block 2025-10-10 12:53:27 +02:00
Marc Seitz 960bb6b1b1 feat: improve team settings loading 2025-10-10 12:33:36 +02:00
Marc SeitzandGitHub 414083df37 Merge pull request #1898 from mfts/feat/only-datarooms
feat: datarooms folders
2025-10-10 11:34:09 +02:00
Marc Seitz ee2bfddfe9 fix 2025-10-10 11:23:44 +02:00
Marc Seitz 8676032c14 fix: return undefined 2025-10-10 09:53:14 +02:00
Marc Seitz 6a096ae235 chore: cleanup 2025-10-09 19:19:10 +02:00
Marc Seitz b9cd4906c9 feat: add ability to isolate dataroom folders 2025-10-09 19:19:04 +02:00
Marc SeitzandGitHub f758ef24c5 Merge pull request #1893 from mfts/cursor/add-custom-welcome-message-to-regular-documents-1f91
Add custom welcome message to regular documents
2025-10-07 21:40:59 +02:00
Marc SeitzandGitHub 9fc302dd55 Merge pull request #1897 from mfts/fix/watermark-config
fix: watermark enabled without config
2025-10-07 21:33:37 +02:00
Marc Seitz 88b6919a59 fix: watermark enabled without config 2025-10-07 21:26:18 +02:00
Marc SeitzandGitHub a62c1d2510 Merge pull request #1896 from mfts/cursor/sanitize-and-validate-team-name-6fcd
Sanitize and validate team name
2025-10-07 19:05:35 +02:00
Marc Seitz 7e0b6fd07c feat: remove branding settings and debounce items 2025-10-07 19:03:42 +02:00
Cursor Agentandmarcftone a5792660a3 feat: Sanitize and validate team names on update
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-07 12:31:11 +00:00
Cursor Agentandmarcftone a57d70771a feat: Add branding settings and welcome message
Co-authored-by: marcftone <marcftone@gmail.com>
2025-10-07 08:16:13 +00:00
Marc SeitzandGitHub e30c6cb678 Merge pull request #1892 from mfts/feat/limts
feat: add agreement to limits
2025-10-04 20:44:52 +02:00
Marc Seitz 8385c6c77c feat: add agreement to limits 2025-10-04 20:39:13 +02:00
Marc SeitzandGitHub 75c21b1300 Merge pull request #1889 from mfts/fix/gif
fix: gif positioning when resizing browser
2025-10-04 20:13:57 +02:00
Marc SeitzandGitHub bb4ecde6fd Merge pull request #1890 from mfts/feat/disclosure
feat: add privacy line
2025-10-03 20:06:14 +02:00
Marc Seitz 23c7eacd36 feat: add privacy line 2025-10-03 19:54:28 +02:00
Marc SeitzandGitHub 52fa820bdc Merge pull request #1891 from mfts/feat/language-issue
fix: auto translate access screen
2025-10-03 19:33:13 +02:00
Marc Seitz 954092ec9a fix: auto translate access screen 2025-10-03 19:15:41 +02:00
Marc Seitz b398c090ba feat: add privacy line 2025-10-01 11:08:06 +02:00
Marc Seitz aceac03f8b fix: gif positioning when resizing browser
closes #1863
2025-10-01 10:36:54 +02:00
Marc SeitzandGitHub 5fa3603767 Merge pull request #1884 from mfts/chore/update-deps
chore: update dependencies
2025-10-01 10:17:33 +02:00
Marc Seitz 4c9dd4be0a fix typo 2025-10-01 10:02:53 +02:00
Marc SeitzandGitHub cbd7919c31 Merge pull request #1888 from mfts/cursor/persist-sidebar-open-state-fe89
Persist sidebar open state
2025-10-01 09:58:34 +02:00
Marc Seitz 376613cc4e chore: update deps 2025-10-01 09:58:15 +02:00
Marc Seitz 843684c649 Merge branch 'main' into chore/update-deps 2025-10-01 09:54:39 +02:00
Marc Seitz 8b67536e82 fix: sidebar open at first 2025-10-01 09:53:06 +02:00
Cursor Agentandmarcftone e6ec5aa99b Fix: Default sidebar to open if cookie is missing
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-30 08:56:46 +00:00
Marc SeitzandGitHub 67fca5771c Merge pull request #1887 from mfts/cursor/add-tsv-download-only-support-1188
Add TSV download only support
2025-09-26 20:32:37 +02:00
Marc Seitz 9e40c914e4 feat: add tsv support 2025-09-26 19:06:52 +02:00
Marc SeitzandGitHub d2417497eb Merge pull request #1885 from mfts/cursor/stop-trial-expiration-emails-for-paying-users-1bef
Stop trial expiration emails for paying users
2025-09-25 15:00:22 +02:00
Marc SeitzandGitHub 761fbd774f Merge pull request #1886 from mfts/cursor/duplicate-permission-group-for-duplicated-links-6c54
Duplicate permission group for duplicated links
2025-09-25 14:50:27 +02:00
Cursor Agentandmarcftone 03540a8915 Add support for TSV files
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-25 12:48:37 +00:00
Marc Seitz ce3520fcce refactor: improve prisma query 2025-09-25 14:02:19 +02:00
Marc Seitz 24ef2b0f4b fix: remove kyOptions from notion 2025-09-25 13:01:20 +02:00
Cursor Agentandmarcftone ff6b30e6f3 feat: Duplicate permission group and access controls when duplicating links
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-25 10:56:54 +00:00
Cursor Agentandmarcftone cef7587281 feat: Add trial plan check to 24h reminder email
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-25 04:51:07 +00:00
Marc SeitzandGitHub 7564417119 Merge pull request #1883 from mfts/feat/show-archived-links
feat: show archived links on documents
2025-09-24 14:26:32 +02:00
Marc Seitz 50ff7f13e2 chore: update dependencies 2025-09-24 14:22:04 +02:00
Marc Seitz c6cb964779 fix: return count for all links 2025-09-24 14:18:44 +02:00
Marc Seitz eafb9b389e feat: show archived links on documents 2025-09-24 14:07:01 +02:00
Marc SeitzandGitHub 8dce83f854 Merge pull request #1882 from mfts/feat/server-side
feat: add revalidation for error props
2025-09-23 18:22:20 +02:00
Marc Seitz 7c106be0f5 feat: add revalidation for error props 2025-09-23 17:19:27 +02:00
Marc SeitzandGitHub 55fad3e9eb Merge pull request #1880 from mfts/feat/improve-pause
feat: improve pause function
2025-09-21 20:01:19 +02:00
Marc Seitz 1a7e94493d faet: improve API key validation with timing-safe comparison 2025-09-21 19:46:48 +02:00
Marc SeitzandGitHub a619d1c3fa Merge pull request #1879 from mfts/feat/index-improvements
feat: improve document processing
2025-09-21 16:58:42 +02:00
Marc Seitz f7030df62f fix 2025-09-21 16:58:14 +02:00
Marc Seitz 1e30da078c Merge branch 'main' into feat/improve-pause 2025-09-21 16:51:51 +02:00
Marc Seitz a9458f4cf7 feat: update pause coupon workflow 2025-09-21 16:51:49 +02:00
Marc Seitz 3b485c82e7 feat: improve document processing 2025-09-21 15:14:24 +02:00
Marc SeitzandGitHub 0457bb4773 Merge pull request #1877 from mfts/feat/index-improvements
feat: speed improvements
2025-09-18 12:15:48 +02:00
Marc Seitz bff0e89273 fix: team.plan 2025-09-18 11:49:54 +02:00
Marc Seitz d78d5c4269 refactor: transaction 2025-09-18 11:39:21 +02:00
Marc Seitz debb862223 feat: add group index 2025-09-18 11:39:01 +02:00
Marc Seitz 2fbf873412 feat: add database index for more efficient queries 2025-09-17 18:49:52 +02:00
Marc Seitz a73a7e8655 refactor: improve prisma queries across the board 2025-09-17 18:49:37 +02:00
Marc Seitz c213bf927b refactor: improve permission groups query 2025-09-17 18:38:49 +02:00
Marc Seitz 761219da63 refactor: improve documents queries 2025-09-17 18:32:01 +02:00
Marc Seitz fe272ebddf chore: update browser list 2025-09-17 18:18:42 +02:00
Marc Seitz 22db982372 refactor: improve dataroom groups query 2025-09-17 18:18:32 +02:00
Marc Seitz f906ebc044 fix 2025-09-17 17:20:48 +02:00
Marc Seitz 713bbef996 refactor: remove massive team/document query for atomic ones 2025-09-17 14:01:45 +02:00
Marc Seitz 154a584c43 refactor: improve document id query 2025-09-17 13:28:58 +02:00
Marc SeitzandGitHub b807dcabd3 Merge pull request #1876 from mfts/feat/passwords
fix: email footer
2025-09-17 11:35:43 +02:00
Marc Seitz b4626defb8 fix: email footer 2025-09-16 21:48:04 +02:00
Marc SeitzandGitHub d69a578c91 Merge pull request #1873 from mfts/cursor/add-spacing-between-logo-and-home-button-39eb
Add spacing between logo and home button
2025-09-16 21:46:34 +02:00
Marc SeitzandGitHub 26030f7dcc Merge pull request #1875 from mfts/feat/passwords
fix: password encryption
2025-09-16 21:45:18 +02:00
Marc SeitzandGitHub d22dcb7277 Merge pull request #1874 from mfts/feat/protect
feat: add ratelimiting
2025-09-16 21:40:06 +02:00
Marc Seitz 6c21e680c3 fix: password encryption 2025-09-16 21:24:33 +02:00
Marc Seitz ba28d663a3 feat: add ratelimiting 2025-09-16 21:14:28 +02:00
Cursor Agentandmarcftone da5c2cf082 Add margin to datarooms breadcrumb
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-16 18:57:39 +00:00
Marc SeitzandGitHub f51a6fa785 Merge pull request #1871 from mfts/feat/speed-improvements
feat: add performance indexes
2025-09-16 01:18:51 +02:00
Marc SeitzandGitHub 4c6499deb9 Merge pull request #1870 from mfts/cursor/add-slack-connection-banner-to-sidebar-31bc
Add slack connection banner to sidebar
2025-09-16 01:05:02 +02:00
Marc Seitz 0121f80cec feat: improve queries 2025-09-16 00:44:46 +02:00
Marc Seitz ce73e73715 feat: add slack banner and slack analytics 2025-09-16 00:17:33 +02:00
Marc Seitz 8df61bca90 feat: add performance indexes 2025-09-15 23:57:38 +02:00
Cursor Agentandyul.schnay f94794653a feat: Add Slack integration banner and logic
Co-authored-by: yul.schnay <yul.schnay@gmail.com>
2025-09-15 21:31:18 +00:00
Marc SeitzandGitHub 676865e99e Merge pull request #1860 from mfts/cursor/add-m4a-and-mp3-audio-support-1c1b
Add m4a and mp3 audio support
2025-09-15 21:51:29 +02:00
Marc Seitz 7ea6840423 Merge branch 'main' into cursor/add-m4a-and-mp3-audio-support-1c1b 2025-09-15 21:01:05 +02:00
Marc Seitz 0058410523 fix: video analytics 2025-09-15 20:50:02 +02:00
Marc Seitz d4fd7776d8 feat: support older m4a mimetype 2025-09-15 20:48:59 +02:00
Marc Seitz 0aed75d4a1 chore: cleanup 2025-09-15 20:48:44 +02:00
Marc SeitzandGitHub ff8f9effb2 Merge pull request #1868 from mfts/feat/annotations
feat: add annotations
2025-09-15 20:03:02 +02:00
Marc Seitz 8b5e28e3d7 Merge branch 'main' into cursor/add-m4a-and-mp3-audio-support-1c1b 2025-09-15 19:54:58 +02:00
Marc Seitz 9792926822 fix 2025-09-15 19:46:59 +02:00
Marc Seitz c802a4d7ce fix 2025-09-15 19:42:45 +02:00
Marc Seitz fd88d11f98 feat: improve annotations 2025-09-15 19:32:38 +02:00
Marc Seitz 6ecbaf7d87 fix: update 2025-09-15 14:55:05 +02:00
Marc Seitz a9aafebd0b Merge branch 'main' into feat/annotations 2025-09-15 14:43:52 +02:00
Marc Seitz 38cc36ba00 feat: add annotations to documents 2025-09-15 14:43:14 +02:00
Marc SeitzandGitHub 3c1ef4a5cb Merge pull request #1867 from mfts/feat/slack-live
feat: enable slack for all
2025-09-14 19:40:18 +02:00
Marc Seitz 7fa72639b6 feat: enable slack for all 2025-09-14 19:23:58 +02:00
Marc SeitzandGitHub 6ac3e266a8 Merge pull request #1865 from mfts/feat/slack
feat: add integrations
2025-09-13 21:33:37 +02:00
Marc Seitz 9e954eac19 fix: slack integration types 2025-09-13 20:52:56 +02:00
Marc Seitz ee55b085e4 feat: make several improvements to slack integration 2025-09-13 20:45:50 +02:00
Marc Seitz fa044d0cd5 chore: remove logs 2025-09-13 19:30:08 +02:00
Marc Seitz 8b4dac1c83 fix: update types 2025-09-13 18:27:46 +02:00
Marc Seitz 8abf39357b chore: logs 2025-09-13 18:02:10 +02:00
Marc Seitz 73fbef9f51 fix: slack session auth 2025-09-13 17:34:13 +02:00
Marc SeitzandGitHub dc36c24cd5 Merge pull request #1852 from AshishViradiya153/slack/reform
Slack/reform
2025-09-13 17:14:59 +02:00
Marc Seitz 6fadbfa212 chore: remove nodemailer 2025-09-13 16:57:26 +02:00
Marc Seitz daf8987141 feat: add featureflag for slack 2025-09-13 16:54:35 +02:00
Marc Seitz f9266fc611 refactor: redirect 2025-09-13 16:54:27 +02:00
Marc Seitz b8a4265edf feat: slack integration improvements and simplification 2025-09-13 16:49:39 +02:00
Marc Seitz 4e86e2a8d9 Merge branch 'main' into slack/reform 2025-09-12 19:13:09 +02:00
Marc SeitzandGitHub 1f5366e152 Merge pull request #1864 from mfts/feat/bulk
feat: extend max duration
2025-09-11 23:27:51 +02:00
Marc Seitz cca9d12eca feat: extend max duration 2025-09-11 22:28:13 +02:00
Marc SeitzandGitHub 4698748803 Merge pull request #1801 from mfts/emails
feat: emails updates and new emails
2025-09-10 23:41:35 +02:00
Marc Seitz 9b99839e51 chore: revert 2025-09-10 23:37:12 +02:00
Marc Seitz 2eba40a4c5 chore: fix 2025-09-10 22:10:34 +02:00
Marc Seitz 103a11e04f fix: lint error 2025-09-10 21:46:21 +02:00
Marc Seitz 4d8856a92e feat(ee): increase limits for max files 2025-09-10 21:46:09 +02:00
Marc Seitz ed336579e8 chore: update dependencies 2025-09-10 21:39:20 +02:00
Marc Seitz b0a4fbe17b fix: lint error 2025-09-10 21:36:44 +02:00
Marc Seitz 2489d0c05e feat: remove trial end reminder cron route 2025-09-10 21:14:44 +02:00
Marc Seitz 3ab376b522 chore: update dependencies 2025-09-10 20:47:39 +02:00
Marc Seitz 6cd1820ce7 feat: cleanup email templates 2025-09-10 20:35:30 +02:00
Marc Seitz 010aa4a4d1 feat: improve welcome email 2025-09-10 20:35:13 +02:00
Marc Seitz 4e856d351f feat: add personal upgrade welcome email 2025-09-10 20:15:34 +02:00
Marc Seitz 0831a047e2 chore: cleanup 2025-09-10 20:15:21 +02:00
Marc Seitz 0999c6bd41 feat: add one month check in trigger 2025-09-10 20:05:28 +02:00
Marc Seitz 81c3a38148 feat: update the upgrade email notification 2025-09-10 20:04:52 +02:00
Marc Seitz 46574bfdd6 feat: remove unused trial end reminder 2025-09-10 20:04:00 +02:00
Marc Seitz a0f70fb05e feat: add footer to team invitation 2025-09-10 19:23:06 +02:00
Marc Seitz bc84b2b84e feat: add dataroom trial 24h end email 2025-09-10 19:18:40 +02:00
Marc Seitz 7e0647f9f7 feat: add datarooms info email 2025-09-10 19:11:30 +02:00
Marc Seitz b71592aafa Merge branch 'main' into emails 2025-09-10 18:54:22 +02:00
Cursor Agentandmarcftone a6d5900e18 Refactor: Rename VideoPlayer to MediaPlayer and add audio support
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-07 17:48:41 +00:00
Marc SeitzandGitHub 33899e693f Merge pull request #1859 from mfts/fix/dataroom-index
feat: server-side detection if dataroom index enabled
2025-09-07 12:35:59 +02:00
Marc SeitzandGitHub 6170b20205 Merge pull request #1850 from mfts/cursor/update-link-agreement-to-show-compliance-text-7d7a
Update link agreement to show compliance text
2025-09-07 12:28:42 +02:00
Marc Seitz 96414bca5a feat: server-side detection if dataroom index enabled 2025-09-07 12:26:46 +02:00
Marc Seitz 3027d7e579 fix: search in domains 2025-09-07 11:29:30 +02:00
Marc Seitz 2060cca3a4 feat: validate and sanitze content 2025-09-07 11:29:22 +02:00
Marc Seitz 992216bb20 Merge branch 'main' into cursor/update-link-agreement-to-show-compliance-text-7d7a 2025-09-07 10:32:16 +02:00
Marc SeitzandGitHub f8bf8aa6f6 Merge pull request #1857 from mfts/mfts-patch-1
Correct grammar in security policy document
2025-09-07 10:30:39 +02:00
Marc Seitz 914b7dd4c5 feat: remove style on agreement 2025-09-07 10:29:42 +02:00
Marc SeitzandGitHub a6a52c4844 Fix grammatical error in SECURITY.md 2025-09-07 10:25:11 +02:00
Marc SeitzandGitHub 66d66ee515 Merge pull request #1858 from mfts/fix/custom-domai
fix: improve custom domains loading
2025-09-07 10:24:23 +02:00
Marc Seitz cf9c3909ff chore: downgrade to prisma 6.5 2025-09-07 10:23:08 +02:00
Marc Seitz 5dc02cf6a5 refactor: prisma schema 2025-09-07 10:23:01 +02:00
Marc Seitz 6142c5746e Merge branch 'main' into cursor/update-link-agreement-to-show-compliance-text-7d7a 2025-09-07 09:49:45 +02:00
Marc Seitz 7c9c7bcaf7 chore: update dependencies 2025-09-07 09:43:21 +02:00
Marc Seitz 82ef46848e fix: custom domain redirected 2025-09-07 09:39:34 +02:00
Marc Seitz 0b37bb9e27 fix: teamId cannot be undefined 2025-09-07 09:39:24 +02:00
Marc Seitz 9c02ad2223 chore: cleanup 2025-09-07 09:38:50 +02:00
Marc SeitzandGitHub ca9dc9b2b1 Correct grammar in security policy document 2025-09-07 07:59:17 +02:00
Marc SeitzandGitHub 7eed8deb32 Merge pull request #1855 from mfts/feat/dataroom-index
feat: add automatic hierarchical index for dataroom items
2025-09-06 14:59:06 +02:00
Marc Seitz 6d38f304b8 feat: improve transaction 2025-09-06 14:54:08 +02:00
Marc Seitz 8c002304d8 chore: cleanup 2025-09-06 14:53:59 +02:00
Marc Seitz fd05a86f50 feat: improve index calculation by using a single transaction with batches 2025-09-06 14:27:16 +02:00
Marc Seitz 2ccb23ddf6 feat: update index generator 2025-09-06 14:14:11 +02:00
Marc Seitz 0c618998b6 chore: update missing types 2025-09-06 14:14:03 +02:00
Marc Seitz 87c649f7f0 fix 2025-09-05 19:13:26 +02:00
Marc Seitz 4fd7369dba feat: add automatic hierarchical index for dataroom items 2025-09-05 19:09:32 +02:00
Marc SeitzandGitHub 8b87f23225 Merge pull request #1853 from mfts/cursor/fix-email-validation-for-university-domains-3a86
Fix email validation for university domains
2025-09-04 17:14:17 +02:00
Cursor Agentandyul.schnay fa2257e564 Improve email regex to support more complex domain structures
Co-authored-by: yul.schnay <yul.schnay@gmail.com>
2025-09-04 13:02:10 +00:00
Ashish Viradiya 16398bbe32 improvement 2025-09-04 11:39:17 +05:30
Ashish Viradiya e842a56dae improvement 2025-09-04 10:42:33 +05:30
Ashish Viradiya 3c6fae3e53 slack integration 2025-09-04 10:14:25 +05:30
Ashish Viradiya 635d39a5ea schema update 2025-09-04 09:59:42 +05:30
Ashish Viradiya 19de3594bc chore: remove date-fns-tz 2025-09-04 09:57:48 +05:30
Ashish Viradiya 0f977e2fb2 schema rebase 2025-09-04 08:40:11 +05:30
Ashish ViradiyaandGitHub 37f0b15ae9 Merge branch 'main' into 421-slack 2025-09-04 08:00:21 +05:30
Marc Seitz e55e40d5fe feat: add agreement statement to access screen 2025-09-03 23:56:21 +02:00
Cursor Agentandmarcftone 2bfc0238e2 Add support for text-based agreements alongside link-based agreements
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-03 13:03:11 +00:00
Marc SeitzandGitHub 4dd874c198 Merge pull request #1849 from mfts/feat/delete-convo
feat: delete convo endpoint
2025-09-02 23:26:52 +02:00
Marc Seitz 32661529e7 fix: remove check 2025-09-02 23:19:55 +02:00
Marc Seitz 63062819eb feat: delete convo endpoint 2025-09-02 23:08:05 +02:00
Marc SeitzandGitHub ace2ed5a4a Merge pull request #1848 from mfts/feat/domain-improv
refactor: improve domain configuration experience
2025-09-02 15:39:11 +02:00
Marc Seitz 952da2c258 fix 2025-09-02 13:20:27 +02:00
Marc Seitz 890d69b2a5 refactor: improve domain configuration experience 2025-09-02 11:43:37 +02:00
Marc Seitz b42cc06989 fix 2025-09-01 13:56:10 +02:00
Marc SeitzandGitHub 9a908d9965 Merge pull request #1847 from mfts/feat/conversations
feat(ee): add publishable q&a conversations
2025-08-30 20:24:52 +02:00
Marc Seitz b22234a28f fix: validation 2025-08-30 20:18:54 +02:00
Marc Seitz dbb6d9d520 feat: improve zod parser 2025-08-30 19:49:36 +02:00
Marc Seitz a4913820c1 feat: upgrade packages 2025-08-30 19:49:25 +02:00
Marc Seitz 070ab6608b refactor: create common zod schema for faqs 2025-08-30 19:28:23 +02:00
Marc Seitz 8311543423 fix 2025-08-30 18:59:00 +02:00
Marc Seitz 1be79bbbfe feat: several improvements to conversations 2025-08-30 18:54:20 +02:00
Marc Seitz 4ac1e36788 feat(ee): add database entry for conversations 2025-08-30 12:25:23 +02:00
Marc Seitz 610257a320 feat(ee): add viewer side conversations faq 2025-08-30 12:21:31 +02:00
Marc Seitz 6e6122861a feat(ee): add dashboard faq conversations 2025-08-30 12:21:00 +02:00
Marc Seitz 7e7f9359ef chore: remove duplicate context component 2025-08-30 12:20:01 +02:00
Marc Seitz 2cec85ad35 chore: remove unused route 2025-08-30 12:19:27 +02:00
Marc Seitz 4c02f435b7 feat: update q&a tab 2025-08-30 12:19:17 +02:00
Marc Seitz c000d99710 refactor(ee): move visitor upload route 2025-08-30 12:19:05 +02:00
Marc Seitz ad5c166938 feat: add new breadcrumb text 2025-08-30 12:18:38 +02:00
Marc Seitz 0445962742 feat: change conversations button in dataroom navbar 2025-08-30 12:15:39 +02:00
Marc Seitz a6e9e50fcc feat(ui): add new notification badge style 2025-08-30 12:15:20 +02:00
Marc SeitzandGitHub b5cc6e15e1 Merge pull request #1845 from mfts/feat/increase-time
feat: increase maxduration
2025-08-28 13:36:35 +02:00
Marc Seitz d8c6660fa1 feat: increase maxduration 2025-08-28 13:26:58 +02:00
Marc SeitzandGitHub 558835261f Merge pull request #1843 from mfts/fix/proxy
refactor: update client proxy
2025-08-27 21:59:35 +02:00
Marc Seitz 9eb480059e refactor: add clientside protection 2025-08-27 21:45:39 +02:00
Marc SeitzandGitHub 8236ae1e89 Merge pull request #1842 from mfts/feat/cleanup
feat: remove internal response headers
2025-08-27 10:03:52 +02:00
Marc Seitz 635e42a4f8 fix 2025-08-27 09:59:32 +02:00
Marc Seitz ddda4ef454 feat: remove internal response headers 2025-08-27 09:45:32 +02:00
Marc SeitzandGitHub f93b083d92 Merge pull request #1841 from mfts/feat/dub
fix: either allow discount or promo codes
2025-08-26 20:42:55 +02:00
Marc Seitz db3d07369f fix: either allow discount or promo codes 2025-08-26 20:19:11 +02:00
Marc SeitzandGitHub 7702eb3ca6 Merge pull request #1838 from mfts/feat/dub
feat: update notion parsing for custom domain
2025-08-26 17:31:18 +02:00
Marc Seitz e6349f9db9 feat: add empty array 2025-08-26 17:07:26 +02:00
Marc Seitz 8679378e88 feat: improve dub discount 2025-08-26 16:53:25 +02:00
Marc Seitz 9428bb2fd2 refactor: improve notion parsing 2025-08-26 16:53:18 +02:00
Marc Seitz 33f60b143a fix: notion parse 2025-08-26 16:37:46 +02:00
Marc Seitz e6d6dcdbaa feat: add dub customer coupon 2025-08-26 16:36:06 +02:00
Marc Seitz f6290e6ab2 feat: update notion parsing for custom domain 2025-08-26 16:24:49 +02:00
Marc SeitzandGitHub 61b96fe360 Merge pull request #1836 from mfts/feat/dub
fix: dataroom without documents show timestamp 0
2025-08-26 13:12:04 +02:00
Marc Seitz 0c77418265 fix: dataroom without documents show timestamp 0 2025-08-26 13:04:11 +02:00
Marc SeitzandGitHub d50d31a31b Merge pull request #1834 from mfts/feat/dub
feat: add dub integration
2025-08-26 11:10:03 +02:00
Marc Seitz 50bef1e894 feat(auth): improve dub auth handler 2025-08-26 11:02:51 +02:00
Marc Seitz e26609769c feat: add dub integration 2025-08-25 12:49:15 +02:00
Marc SeitzandGitHub 4bdf311cc0 Merge pull request #1833 from mfts/feat/revalidation
feat: revalidate every 60s
2025-08-25 11:14:55 +02:00
Marc Seitz f5c2403c57 feat: revalidate every 60s 2025-08-25 11:07:49 +02:00
Marc SeitzandGitHub 22a1d1a171 Merge pull request #1832 from mfts/fix/webhook 2025-08-24 19:12:27 +02:00
Marc Seitz 3e087e6bb1 feat: improve extension validation 2025-08-24 19:05:08 +02:00
Marc Seitz a4d96145b6 feat: fix missing extension 2025-08-24 18:49:49 +02:00
Marc SeitzandGitHub 1a44cb7e71 Merge pull request #1831 from mfts/fix/issue
fix
2025-08-23 16:58:59 +02:00
Marc Seitz c7450cae35 fix 2025-08-23 16:53:38 +02:00
Marc SeitzandGitHub ead8f8a195 Merge pull request #1830 from mfts/fix/issue
fix: url validation for notion
2025-08-23 16:42:47 +02:00
Marc Seitz f3107d172d fix 2025-08-23 16:37:43 +02:00
Marc Seitz 8b534abef9 fix: validation 2025-08-23 16:33:58 +02:00
Marc Seitz ae07c72f20 fix: validation for notion 2025-08-23 16:25:50 +02:00
Marc SeitzandGitHub e536082051 Merge pull request #1829 from mfts/fix/issue
feat: add url validation
2025-08-23 15:58:37 +02:00
Marc Seitz 9c8d7caa45 feat: improve validation 2025-08-23 15:54:05 +02:00
Marc Seitz 72fc18d1b0 feat: add url validation 2025-08-23 15:34:34 +02:00
Marc SeitzandGitHub ef3b1a23e4 Merge pull request #1826 from mfts/marc/pm-431-bug-link-presets-sets-a-group-links-allow-list-and-therefore
fix: ignore allowlist/denylist from group links via presets
2025-08-22 09:13:30 +02:00
Marc Seitz 3d2500b109 fix 2025-08-22 09:04:41 +02:00
Marc Seitz b3c8b11295 fix: ignore allowlist/denylist from group links via presets 2025-08-21 22:49:44 +02:00
Marc SeitzandGitHub 5cf3b33d96 Merge pull request #1824 from mfts/feat/error-fixes
fix: conversion scaling for long documents
2025-08-21 15:28:24 +02:00
Marc Seitz e954014f7f fix: conversion scaling for long documents 2025-08-21 14:59:18 +02:00
Marc SeitzandGitHub 297a81326e Merge pull request #1823 from mfts/fix/notion
feat: add separate function to fix internal notion api change
2025-08-20 12:20:00 +02:00
Marc Seitz 8c08d6c99c feat: add separate function to fix internal notion api change 2025-08-20 12:12:21 +02:00
Marc Seitz bd45eddadb feat: update email text 2025-08-20 11:49:03 +02:00
Marc Seitz ec31082790 chore: remove unused email 2025-08-20 11:48:54 +02:00
Marc Seitz a6d0553cf5 feat: upgrade react-email packages 2025-08-20 11:48:40 +02:00
Marc Seitz 0774f29438 Merge branch 'main' into emails 2025-08-19 10:01:44 +02:00
Marc SeitzandGitHub 5c80c410df Merge pull request #1821 from mfts/feat/dataroom-bulk-download
feat: add toggle to disallow bulk download
2025-08-19 09:52:53 +02:00
Marc Seitz 9d8f0e5998 feat: add toggle to disallow bulk download 2025-08-19 09:20:29 +02:00
Marc SeitzandGitHub bebe96dd5c Merge pull request #1820 from mfts/chore/depss
feat: update fallback plan
2025-08-18 21:25:08 +02:00
Marc Seitz efb7ba9e4f feat: fix plan null 2025-08-18 21:07:06 +02:00
Marc Seitz 0590bab122 feat: don't default to free plan 2025-08-18 19:31:46 +02:00
Marc Seitz d014bdfefa chore: update deps 2025-08-18 19:31:28 +02:00
Marc SeitzandGitHub faf99e9ef3 Merge pull request #1817 from mfts/fix/pause
fix: update pause behavior
2025-08-16 16:27:39 +02:00
Marc Seitz 2a791d0ca3 fix: improvement 2025-08-16 00:52:44 +02:00
Marc Seitz 18bfebb11e fix: update pause behavior 2025-08-16 00:46:03 +02:00
Marc SeitzandGitHub 294efb89dd Merge pull request #1815 from mfts/feat/add-create-dataroom
feat: dynamically load code imports
2025-08-16 00:06:00 +02:00
Marc Seitz d926083097 feat: dynamically load code imports 2025-08-15 23:57:18 +02:00
Marc SeitzandGitHub fbee53aeb2 Merge pull request #1814 from mfts/mfts-patch-1
Create SECURITY.md
2025-08-15 22:43:17 +02:00
Marc SeitzandGitHub d18cb16461 Create SECURITY.md 2025-08-15 22:35:46 +02:00
Marc SeitzandGitHub 05cda54338 Merge pull request #1813 from mfts/feat/bulk
feat: allow download doc-less folders
2025-08-14 22:31:27 +02:00
Marc Seitz ac74bd2be0 feat: bump duration 2025-08-14 22:14:49 +02:00
Marc Seitz d5df006e64 fix 2 2025-08-14 22:11:19 +02:00
Marc SeitzandGitHub 4637d71bb2 Merge pull request #1812 from mfts/feat/bulk 2025-08-14 20:47:33 +02:00
Marc Seitz 7fa983977b fix 2025-08-14 20:41:26 +02:00
Marc Seitz 15dd0eff53 feat: extend 2025-08-14 19:34:39 +02:00
Marc SeitzandGitHub c6f08be03d Merge pull request #1811 from mfts/fix/invocations
feat: introduce ratelimiting to endpoint
2025-08-12 21:28:46 +02:00
Marc Seitz 8be2fa5366 feat: introduce ratelimiting to endpoint 2025-08-12 21:13:56 +02:00
Marc SeitzandGitHub ffc99b7e67 Merge pull request #1810 from mfts/fix/printscreen
feat: improve screenshot protection
2025-08-11 00:25:02 +02:00
Marc Seitz 42d05ed647 fix typo 2025-08-11 00:07:49 +02:00
Marc Seitz 2852de5572 feat: improve screenshot protection 2025-08-10 23:55:32 +02:00
Marc SeitzandGitHub e9bbaac5a0 Merge pull request #1808 from mfts/fix/quantity
fix: minimum quantity error
2025-08-09 14:35:19 +02:00
Marc SeitzGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
c1f49e855e Potential fix for code scanning alert no. 66: Use of externally-controlled format string
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-09 14:28:24 +02:00
Marc Seitz 9dcd9b68af fix: minimum quantity error 2025-08-09 14:22:45 +02:00
Marc SeitzandGitHub daefbde9bf Merge pull request #1805 from AshishViradiya153/427/500Error
attempt fix: user unable to log in
2025-08-09 13:56:57 +02:00
Ashish Viradiya 6f92160d57 attempt fix: user unable to log in 2025-08-07 20:46:09 +05:30
Ashish ViradiyaandGitHub 8ec18ea0c3 Merge branch 'main' into 421-slack 2025-08-07 19:16:19 +05:30
Ashish Viradiya 95c548f144 slack integration improvement 2025-08-07 19:02:26 +05:30
Marc SeitzandGitHub 78554ded87 Merge pull request #1804 from mfts/cursor/export-custom-fields-as-label-value-pairs-79e5
feat: export custom fields as label value pairs 79e5
2025-08-07 09:39:36 +02:00
Marc Seitz d335c7f1f7 feat: improve dataroom view lookup 2025-08-07 09:27:27 +02:00
Marc Seitz 67550162f2 fix: improve exports with nan 2025-08-07 09:11:54 +02:00
Marc Seitz e7e01dc6f0 Merge branch 'main' into cursor/export-custom-fields-as-label-value-pairs-79e5 2025-08-06 20:06:37 +02:00
Marc SeitzandGitHub 6a604d7a13 Merge pull request #1803 from mfts/cursor/add-no-datarooms-included-to-pro-plan-12af
feat: add no datarooms included to pro plan 12af
2025-08-06 20:06:10 +02:00
Marc Seitz 615ac0220f Merge branch 'main' into cursor/add-no-datarooms-included-to-pro-plan-12af 2025-08-06 19:57:56 +02:00
Marc SeitzandGitHub 029d765f5d Merge pull request #1797 from mfts/feat/cancel-flow
feat: v1 cancel
2025-08-06 19:36:48 +02:00
Marc Seitz 0cdc74258f refactor: change plan function 2025-08-06 19:30:08 +02:00
Marc Seitz c2182a6042 feat: improve 2025-08-06 19:29:51 +02:00
Marc Seitz e219c53739 feat: improve roles 2025-08-06 19:29:32 +02:00
Marc Seitz 1bb7e79a37 chore: cleanup 2025-08-06 19:29:22 +02:00
Marc Seitz bf7c9ff5d4 fix 2025-08-06 19:04:56 +02:00
Marc Seitz c652ad607b feat(ee): add native billing flow 2025-08-06 18:55:13 +02:00
Cursor Agentandmarcftone 141def2c54 Refactor custom fields export to support dynamic and consistent column generation
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-06 07:11:28 +00:00
Ashish Viradiya 31e221f175 dark mode fix 2025-08-06 01:39:49 +05:30
Ashish Viradiya ad04e8859f improvement 2025-08-06 01:32:29 +05:30
Ashish Viradiya 90aba22628 chore: package-lock file 2025-08-05 22:56:43 +05:30
Ashish ViradiyaandGitHub cf704906b4 Merge branch 'main' into 421-slack 2025-08-05 22:52:56 +05:30
Ashish Viradiya fdcc36d961 Feat: Slack native integration 2025-08-05 22:45:50 +05:30
Marc Seitz a923ce6dfb refactor: change price id retrieval function 2025-08-04 21:09:09 +02:00
Iuliia Shnai 0e92372907 feat: emails updates and new emails. 2025-08-04 20:11:51 +02:00
Cursor Agentandmarcftone 7de964e4f0 Add feature flag for not included features in plan comparison
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-04 09:43:41 +00:00
Marc SeitzandGitHub f386f275e6 Merge pull request #1800 from mfts/fix/watermar
feat: keep original name for watermarked document
2025-08-04 11:18:57 +02:00
Marc Seitz 46d8922f59 feat: keep original name for watermarked document 2025-08-04 10:58:04 +02:00
Marc SeitzandGitHub 44292347f1 Merge pull request #1799 from mfts/cursor/add-phone-number-field-to-custom-fields-58b7
feat: add phone number field to custom fields 58b7
2025-08-04 10:39:09 +02:00
Marc Seitz c6fc0b824b fix: component height 2025-08-04 10:26:00 +02:00
Marc Seitz eb70302dd2 feat: upgrade phone input component 2025-08-04 10:23:42 +02:00
Marc SeitzandGitHub 0f3aeb5ea7 Merge pull request #1798 from mfts/cursor/track-teamid-in-link-viewed-event-112e
feat: track teamid in link viewed event 112e
2025-08-03 20:17:50 +02:00
Marc Seitz 0dfdd2565e feat: add teamid to link viewed events 2025-08-03 20:11:51 +02:00
Marc Seitz a8560359fd feat: v1 cancel 2025-08-03 19:27:23 +02:00
Cursor Agentandmarcftone a4cb2e4e72 Pass teamId to tracking context in document and dataroom views
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-03 16:38:17 +00:00
Cursor Agentandmarcftone 9c8841620a Add phone number support for custom fields in access form
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-03 09:21:09 +00:00
Marc SeitzandGitHub 0df6b96d68 Merge pull request #1795 from mfts/cursor/add-document-and-page-number-context-48ff
feat(ee): add document and page number context 48ff
2025-08-02 20:54:08 +02:00
Marc Seitz 9226ba5a70 feat(ee): add conversation context 2025-08-02 20:40:30 +02:00
Marc Seitz 927796c0cd Merge branch 'main' into cursor/add-document-and-page-number-context-48ff 2025-08-02 18:34:59 +02:00
Marc SeitzandGitHub 3988e6af49 Merge pull request #1794 from mfts/cursor/notify-team-members-of-new-conversation-messages-311d
feat(ee): notify team members of new conversation messages 311d
2025-08-02 18:30:17 +02:00
Marc Seitz f962b97729 fix 2025-08-02 18:23:12 +02:00
Marc Seitz 2b918234d8 fix: validation 2025-08-02 18:20:34 +02:00
Marc Seitz 07c2821d4e feat(ee): update conversation notifications for team members 2025-08-02 16:18:53 +02:00
Cursor Agentandmarcftone 6fe27092d4 Add team member notifications for conversations in datarooms
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-02 10:55:02 +00:00
Marc SeitzandGitHub 09e1d58d07 Merge pull request #1793 from mfts/feat/rtf-support
feat: add rtf / txt file support
2025-08-02 12:50:13 +02:00
Cursor Agentandmarcftone b66dd7f3d1 Add document context and improve conversation title display
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-02 09:32:41 +00:00
Marc Seitz 179a93c31b feat: add rtf / txt file support 2025-08-02 11:23:33 +02:00
Marc SeitzandGitHub 9d50baec54 Merge pull request #1792 from mfts/cursor/implement-multipart-file-uploads-with-pre-signed-urls-3ae0
feat: implement multipart file uploads with pre signed urls 3ae0
2025-07-31 22:02:03 +02:00
Marc Seitz afc210d9cb feat: add abort multipart 2025-07-31 21:43:59 +02:00
Marc Seitz cb05dd0656 feat: add validation 2025-07-31 21:42:47 +02:00
Marc Seitz ea59fdbdc5 fix 2025-07-31 13:37:49 +02:00
Marc Seitz 116a903489 feat: add multi part upload 2025-07-31 13:37:30 +02:00
Marc Seitz 73798ce172 Merge branch 'main' into cursor/implement-multipart-file-uploads-with-pre-signed-urls-3ae0 2025-07-30 20:55:44 +02:00
Marc SeitzandGitHub be8d482efc Merge pull request #1791 from mfts/feat/changeint
fix: change fileSize to bigint
2025-07-30 14:46:05 +02:00
Marc Seitz 1a9b75a1a4 feat: add serialization for bigint from database to client
cast bigint to number because js number is basically bigint
2025-07-30 14:37:15 +02:00
Marc Seitz e1803c1f01 fix: change fileSize to bigint 2025-07-30 13:39:16 +02:00
Cursor Agentandmarcftone ff3ee45c14 Implement multipart S3 upload with parallel processing for large files
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-30 09:09:58 +00:00
Marc SeitzandGitHub 3f3e8c8377 Merge pull request #1790 from mfts/feat/add-gtm
fix: passkeys
2025-07-29 18:46:56 +02:00
Marc Seitz be52b37f3d feat: don't send event if already sent 2025-07-29 18:39:55 +02:00
Marc Seitz 1afc0832fc fix: passkey username required 2025-07-29 18:39:27 +02:00
Marc SeitzandGitHub fcf5be17e6 Merge pull request #1789 from mfts/feat/add-gtm
feat: add gtm
2025-07-29 17:51:23 +02:00
Marc Seitz 9d81120017 chore: update dependencies 2025-07-29 17:45:01 +02:00
Marc Seitz 30e8c6e8cf feat: add gtm 2025-07-29 17:41:14 +02:00
Marc SeitzandGitHub 47b91488cb Merge pull request #1788 from mfts/cursor/add-cancel-export-button-to-stop-trigger-run-7af9
feat: add cancel export button and recent exports
2025-07-29 13:00:14 +02:00
Marc Seitz d28a2a257c feat: show recent exports 2025-07-29 12:46:43 +02:00
Marc Seitz 428c0fbd2d chore: cleanup cancel button 2025-07-29 11:18:58 +02:00
Cursor Agentandmarcftone fb5e4ef47f Refactor: Use ExportJobCleanupItem interface in RedisJobStore
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:50:53 +00:00
Cursor Agentandmarcftone e404919c45 Handle Redis client auto-parsing of job store items
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:35:13 +00:00
Cursor Agentandmarcftone 589902deb1 Add export job cancellation support for background tasks
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:26:48 +00:00
Marc SeitzandGitHub 31d44dac27 Merge pull request #1787 from mfts/fix/passkeys
feat: list passkeys
2025-07-28 21:07:43 +02:00
Marc Seitz 15e8d1c7d7 fix 2025-07-28 20:46:22 +02:00
Marc Seitz 20f423c3c1 fix: improve passkey requests 2025-07-28 20:41:23 +02:00
Marc SeitzandGitHub 7b8bd28ca5 Merge pull request #1786 from mfts/cursor/add-notion-public-access-checker-and-url-management-b997
feat: add notion access checker
2025-07-28 20:29:41 +02:00
Marc Seitz 13266da720 fix 2025-07-28 19:15:09 +02:00
Marc Seitz bfe0cdc963 feat: list passkeys 2025-07-28 19:12:46 +02:00
Marc Seitz bc1671f063 fix 2025-07-28 18:39:44 +02:00
Marc Seitz e99accc2ec feat: add notion indicator 2025-07-28 18:10:11 +02:00
Cursor Agentandmarcftone 59f27b3afd Add Notion document accessibility indicator and URL management
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-28 12:42:16 +00:00
Marc SeitzandGitHub bf246ff863 Merge pull request #1784 from mfts/feat/breakpoint-improve
feat: improve useBreakpoint hook performance and accuracy
2025-07-26 11:44:23 +02:00
Marc Seitz 2a1d1465cc fix: 2025-07-26 11:29:19 +02:00
Marc Seitz b8cbf94b4d feat: improve useBreakpoint hook performance and accuracy
Refactored useBreakpoint to use matchMedia for more efficient breakpoint detection and added a debounced resize listener for edge cases. The hook now initializes state as undefined and ensures state updates only when the value changes, returning a boolean for consistent usage.
2025-07-26 11:17:26 +02:00
Marc SeitzandGitHub adc450c899 Merge pull request #1748 from AshishViradiya153/smallScreenUi
improvement: small screen shows only icon in btn with tooltip
2025-07-25 20:48:10 +02:00
Marc SeitzandGitHub 0d55dba60d Merge pull request #1768 from AshishViradiya153/407/customeLabel
added ability to change 'Your action is requested'
2025-07-25 20:38:28 +02:00
Marc Seitz f0d08fe2a4 feat: make add documents always big 2025-07-25 20:37:23 +02:00
Marc Seitz b0fbddbd60 refactor: move responsive button to ui directory 2025-07-25 20:37:13 +02:00
Marc Seitz 3d722e18ec feat: update welcome message input w/ sanitzation 2025-07-25 20:25:58 +02:00
Marc Seitz 4c6d90ac5d chore: delete unused page 2025-07-25 20:25:41 +02:00
Marc Seitz e4601bbd41 chore: update migration 2025-07-25 20:25:34 +02:00
Marc Seitz b437a4ba04 Merge branch 'main' into 407/customeLabel 2025-07-25 19:50:58 +02:00
Marc SeitzandGitHub 95c60ae564 Merge pull request #1733 from AshishViradiya153/380/upgradeBtn
Improvement: Unify upgrade buttons everywhere
2025-07-25 19:50:37 +02:00
Marc Seitz c0da96f123 Merge branch 'main' into 407/customeLabel 2025-07-25 19:49:51 +02:00
Marc Seitz 7198b4c205 chore: update plan text on dr notifications 2025-07-25 19:38:12 +02:00
Marc Seitz a13c7d9a77 docs: update trial banner 2025-07-25 19:36:37 +02:00
Marc SeitzandGitHub 680fd69f4d Merge pull request #1776 from AshishViradiya153/414/emailNotification
improvement: notify team if blocked email tried to access a link
2025-07-25 19:31:46 +02:00
Marc Seitz f4d21c64e4 Merge branch 'main' into 380/upgradeBtn 2025-07-25 19:25:31 +02:00
Marc SeitzandGitHub 7144f46152 Merge pull request #1779 from AshishViradiya153/planModal/highlight
improvement: Added highlights when click on adding folder
2025-07-25 19:24:56 +02:00
Marc Seitz 2da5c31de7 fix: improvements 2025-07-25 19:24:20 +02:00
Marc Seitz 3844d87d6e Merge branch 'main' into 380/upgradeBtn 2025-07-25 19:21:21 +02:00
Marc Seitz 373a15672c chore: unused import 2025-07-25 19:11:42 +02:00
Marc Seitz d023c95272 feat(ee): send blocked access email notifications 2025-07-25 19:09:57 +02:00
Marc Seitz e6c8b4b80f chore: undo email notifications 2025-07-25 19:09:39 +02:00
Marc Seitz 6e6595b709 Merge branch 'main' into 414/emailNotification 2025-07-25 18:11:59 +02:00
Marc SeitzandGitHub 132c9815fa Merge pull request #1782 from mfts/fix/watermark
fix: add missing needsWatermark on dataroom folder
2025-07-24 23:45:50 +02:00
Marc Seitz 48f443bb97 fix: add missing needsWatermark on dataroom folder 2025-07-24 23:36:27 +02:00
Marc SeitzandGitHub 4d77cfe342 Merge pull request #1781 from mfts/fix/watermark
fix: watermark on partial folder downloads
2025-07-24 23:15:28 +02:00
Marc Seitz c64a83400b fix: watermark on partial folder downloads 2025-07-24 23:07:43 +02:00
Marc SeitzandGitHub 3e54f057a0 Merge pull request #1780 from mfts/fix/codeql
fix: codeql
2025-07-24 22:45:08 +02:00
Marc Seitz ef1a00d712 fix 2025-07-24 14:53:03 +02:00
Marc Seitz a65049fe8d fix 2025-07-24 14:49:40 +02:00
Marc Seitz 29236ae6b6 fix: codeql 2025-07-24 14:44:00 +02:00
Ashish Viradiya 603b9bd3e7 improvement: Added highlights when click on adding folder 2025-07-24 18:00:52 +05:30
Marc Seitz 1eb0e56f67 chore: dependency updates 2025-07-24 14:30:12 +02:00
Marc Seitz 9c29192245 refactor: remove react-syntax-highlighter for shiki 2025-07-24 14:08:29 +02:00
Marc SeitzandGitHub d7440bfe5e Merge pull request #1778 from mfts/fix/codeql
fix: improve code coverage
2025-07-24 13:58:05 +02:00
Marc Seitz aad1e31d17 feat: protect endpoints 2025-07-24 13:49:25 +02:00
Marc Seitz 1545332b8b feat: add zod validations for fetch url inputs 2025-07-24 13:40:49 +02:00
Marc SeitzandGitHub 03f79d3f57 Merge pull request #1777 from mfts/fix/codeql
fix: codeql
2025-07-24 08:42:40 +02:00
Marc Seitz 3c4a4a254d feat: add shared folder path validation schema
Introduced a Zod schema for validating folder path parameters to prevent type confusion and path traversal attacks. Updated all relevant API routes to use this schema for input validation, improving security and consistency across folder-related endpoints.
2025-07-24 08:30:36 +02:00
Marc Seitz ab73dbc66f fix: remove handlebars and validate separately 2025-07-24 08:18:32 +02:00
Marc SeitzandGitHub daf2d9b4a2 Merge pull request #1772 from AshishViradiya153/411/wordbreak
Fix: prevent long document names from overflowing modal dialog
2025-07-23 21:11:07 +02:00
Marc Seitz bdd67017b8 Merge branch 'main' into 411/wordbreak 2025-07-23 21:05:19 +02:00
Marc Seitz 0e4d45a28a fix: add overflow truncation to move document to folder modal 2025-07-23 21:04:51 +02:00
Marc SeitzandGitHub e1e7831513 Merge pull request #1726 from AshishViradiya153/382/popup
Added data room trial button on each popup
2025-07-23 20:48:10 +02:00
Marc Seitz d761f78873 Merge branch 'main' into 411/wordbreak 2025-07-23 20:47:42 +02:00
Marc Seitz 13f3de029d chore: update deps 2025-07-23 20:41:12 +02:00
Marc Seitz b57487b9a3 Merge branch 'main' into 382/popup 2025-07-23 20:39:19 +02:00
Ashish Viradiya 84e206e484 define type 2025-07-21 19:39:13 +05:30
Ashish Viradiya 2705c580f3 improvement: notify team if blocked email tried to access a link 2025-07-21 18:54:34 +05:30
Ashish Viradiya e5a1c11a80 fix option selection text overflow 2025-07-21 09:03:16 +05:30
Ashish Viradiya f0d79474e0 Fix: prevent long document names from overflowing modal dialog 2025-07-21 08:24:19 +05:30
Ashish Viradiya 950f7e12e3 added ability to change 'Your action is requested' 2025-07-17 04:26:31 +05:30
Ashish Viradiya 0503d904ac improvement 2025-07-08 23:03:46 +05:30
Ashish Viradiya 185d699cad improvement: small screen shows only icon in btn with tooltip 2025-07-08 21:23:09 +05:30
Ashish Viradiya 7385393342 update 2025-07-02 02:56:37 +05:30
Ashish Viradiya f70b1c06e0 Improvement: Unify upgrade buttons everywhere 2025-07-02 00:32:38 +05:30
Ashish Viradiya e569677358 Added data room trial button on each popup 2025-06-30 18:11:25 +05:30
762 changed files with 70983 additions and 13619 deletions
-1
View File
@@ -1 +0,0 @@
oss-gg
+1 -1
View File
@@ -2,7 +2,7 @@ Copyright (c) 2023-present Papermark, Inc.
Portions of this software are licensed as follows:
- All content that resides under https://github.com/mfts/papermark/tree/main/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
- 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".
- All third party components incorporated into the Papermark Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
Generated
+359 -296
View File
@@ -34,209 +34,252 @@
},
"black": {
"hashes": [
"sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f",
"sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd",
"sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea",
"sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981",
"sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b",
"sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7",
"sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8",
"sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175",
"sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d",
"sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392",
"sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad",
"sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f",
"sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f",
"sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b",
"sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875",
"sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3",
"sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800",
"sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65",
"sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2",
"sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812",
"sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50",
"sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"
"sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783",
"sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f",
"sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5",
"sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf",
"sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43",
"sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea",
"sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be",
"sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f",
"sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d",
"sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892",
"sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a",
"sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828",
"sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655",
"sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a",
"sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b",
"sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7",
"sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f",
"sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83",
"sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f",
"sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5",
"sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5",
"sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da",
"sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce",
"sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59",
"sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a",
"sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b",
"sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"
],
"version": "==24.10.0"
"version": "==25.12.0"
},
"certifi": {
"hashes": [
"sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56",
"sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"
"sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
"sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
],
"markers": "python_version >= '3.6'",
"version": "==2024.12.14"
"markers": "python_version >= '3.7'",
"version": "==2025.11.12"
},
"cffi": {
"hashes": [
"sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8",
"sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2",
"sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1",
"sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15",
"sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36",
"sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824",
"sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8",
"sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36",
"sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17",
"sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf",
"sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc",
"sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3",
"sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed",
"sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702",
"sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1",
"sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8",
"sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903",
"sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6",
"sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d",
"sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b",
"sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e",
"sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be",
"sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c",
"sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683",
"sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9",
"sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c",
"sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8",
"sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1",
"sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4",
"sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655",
"sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67",
"sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595",
"sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0",
"sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65",
"sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41",
"sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6",
"sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401",
"sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6",
"sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3",
"sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16",
"sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93",
"sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e",
"sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4",
"sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964",
"sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c",
"sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576",
"sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0",
"sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3",
"sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662",
"sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3",
"sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff",
"sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5",
"sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd",
"sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f",
"sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5",
"sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14",
"sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d",
"sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9",
"sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7",
"sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382",
"sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a",
"sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e",
"sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a",
"sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4",
"sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99",
"sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87",
"sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_version >= '3.8'",
"version": "==1.17.1"
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537",
"sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa",
"sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a",
"sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294",
"sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b",
"sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd",
"sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601",
"sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd",
"sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4",
"sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d",
"sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2",
"sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313",
"sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd",
"sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa",
"sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8",
"sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1",
"sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2",
"sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496",
"sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d",
"sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b",
"sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e",
"sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a",
"sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4",
"sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca",
"sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78",
"sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408",
"sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5",
"sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3",
"sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f",
"sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a",
"sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765",
"sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6",
"sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146",
"sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6",
"sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9",
"sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd",
"sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c",
"sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f",
"sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545",
"sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176",
"sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770",
"sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824",
"sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f",
"sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf",
"sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487",
"sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d",
"sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd",
"sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b",
"sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534",
"sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f",
"sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b",
"sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9",
"sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd",
"sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125",
"sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9",
"sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de",
"sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11",
"sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d",
"sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35",
"sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f",
"sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda",
"sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7",
"sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a",
"sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971",
"sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8",
"sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41",
"sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d",
"sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f",
"sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757",
"sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a",
"sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886",
"sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77",
"sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76",
"sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247",
"sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85",
"sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb",
"sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7",
"sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e",
"sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6",
"sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037",
"sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1",
"sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e",
"sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807",
"sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407",
"sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c",
"sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12",
"sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3",
"sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089",
"sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd",
"sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e",
"sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00",
"sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.1"
"version": "==3.4.4"
},
"click": {
"hashes": [
@@ -248,30 +291,22 @@
},
"clickhouse-toolset": {
"hashes": [
"sha256:0d9b5f1c306e6ca1641776c55fc445c6ef700e459502a2343706566dd8527a1a",
"sha256:0f906fbb04a00088bf24394501f5e6c47582a5232939a1441bb511f5fb00b522",
"sha256:14a4616b6b5b2bbc024bce0665861a8c52e67250f52d64acf0fc33d42b18ed1b",
"sha256:29adeb6a17bacf4c9548c6203f3d352fda3fecbfd31669008f4befb9ab94b80b",
"sha256:360cd8b534415e28bb2ade8662d788997a8ec626eb2e46413da4bb6cd89a6804",
"sha256:4836cec2f14a0d466bd1ea0ca8e99211e42f5a9e59d8e853c093f1cfcd8e43cd",
"sha256:64f0013cf2f630363d2d634bee90e3756693a549e28521851b0a45e09c3741bf",
"sha256:6bf908bc72775f27993f17f87dad8ec67c577a65e6461df3f298f86b8f53a6a5",
"sha256:7ebc217ab4442189611fd8e12a8a99927fe126cf87f2cd6db42cd6851828ec84",
"sha256:82a3b89d06e17c64e80b8aaec1a719427741e24f90894f2de3276df627310e09",
"sha256:831b507f3b1d89049ef46cad6cebceb9f9274fd659dba6aae4fc2f3a950b2074",
"sha256:84927b186d231dfe5486f5e00061da7b20b87df35d895dffc9ced3ac01ecd2bc",
"sha256:85424e2a801904adf408b957fd61553dee8a514b0bbffc0ec7278624f20d80b8",
"sha256:8991d64f9208339d165db748563105927886656791af0b1492c5c01fcd9b2d7f",
"sha256:9270dea4b3a363d0542492453a5c991b230480f1b6d1703d869ad178af5ff078",
"sha256:b8bc27aaa36e54d90e8db4fa30b202855a92fd4b3a093387bef0e0e52cfaae64",
"sha256:bfe9e6df80feb35217f7e85218ef0c4abd6e9eed96e90dcf63be86de704fa29c",
"sha256:d76d1484be2055f7479e96969f8d5c57d53a4322d108be200aaffcc23904bbc8",
"sha256:df2cd28761ec673e13881219fc03081ea733e1af85c45a5a4fb25a0be68d133e",
"sha256:e7c4bf2bce1f547bff03d02e0cfa22b837c45a9eca1ffafb117242cd304d6856",
"sha256:fab34e6755b651c5ed6f8aec4313358fe6b2bf9f460c5493043dacb1cac90ba2"
"sha256:259911a325dafa89e7ee8637e701eebcf24a0ed775b45532d91861fd19d81dec",
"sha256:26ab6d2b4cc60bdf64228ace795867f135d9ccbb193b4f184f4233a01955d6e5",
"sha256:26c9dca366bdcb167aee50208e48bcfccc1e3b7225385ae3cc3563619848addd",
"sha256:337d2dd6de8f95722945d48fd33a4abc0a7ea0ca81a26d2fcd661764408434ca",
"sha256:4ea62ffd811449f0d77b8fdeab699a0719465463d4e614d9633d9fbb7b99ec29",
"sha256:51a23b0704a40d9d2dfbbeb4e6bf1ba426e3b5d0e35221ee7771716786bde138",
"sha256:8ef49b41c7e8eabb504e6c401ec1e6b4067babf745bae1368135a0727f50ac86",
"sha256:9fa163cf88cce25b0d3a2344b237bd56ee5cd55d1c114d74f24be10a0d7ab74f",
"sha256:b2eea180d5665528134bfddec4048c661fc044fa8970bedc9593b1db29627d7e",
"sha256:ccbeba4f5dfa67f55d27533935e29e70071bb7eff288d1efbbe64e01b2f6e89a",
"sha256:d50285d528d932779b2dc64be245a255782111d20a9ddd8369d19ff6ed3a78e9",
"sha256:dd15c3189149e1d0b45039f95e4c19c036618eee98a586627f825e70881cbf51",
"sha256:fa84e14fb9c3e3c3208bf022608302d7b7024cba4a2e849be8faf689da4505e7"
],
"markers": "python_version < '3.13' and python_version >= '3.8'",
"version": "==0.33.dev0"
"markers": "python_version < '3.14' and python_version >= '3.8'",
"version": "==0.34.dev0"
},
"colorama": {
"hashes": [
@@ -283,11 +318,11 @@
},
"croniter": {
"hashes": [
"sha256:32a5ec04e97ec0837bcdf013767abd2e71cceeefd3c2e14c804098ce51ad6cd9",
"sha256:d6ed8386d5f4bbb29419dc1b65c4909c04a2322bd15ec0dc5b2877bfa1b75c7a"
"sha256:924a38fda88f675ec6835667e1d32ac37ff0d65509c2152729d16ff205e32a65",
"sha256:f17f877be1d93b9e3191151584a19d8b367b017ab0febc8c5472b9300da61c4c"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.3.8"
"version": "==1.3.15"
},
"cryptography": {
"hashes": [
@@ -328,11 +363,11 @@
},
"gitpython": {
"hashes": [
"sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110",
"sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"
"sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c",
"sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.44"
"version": "==3.1.45"
},
"humanfriendly": {
"hashes": [
@@ -344,19 +379,19 @@
},
"idna": {
"hashes": [
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.6'",
"version": "==3.10"
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"mypy-extensions": {
"hashes": [
"sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
"sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
],
"markers": "python_version >= '3.5'",
"version": "==1.0.0"
"markers": "python_version >= '3.8'",
"version": "==1.1.0"
},
"packaging": {
"hashes": [
@@ -384,11 +419,11 @@
},
"pycparser": {
"hashes": [
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
"sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
],
"markers": "python_version >= '3.8'",
"version": "==2.22"
"markers": "implementation_name != 'PyPy'",
"version": "==2.23"
},
"pydantic": {
"hashes": [
@@ -507,72 +542,100 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.9.0.post0"
},
"pyyaml": {
"pytokens": {
"hashes": [
"sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff",
"sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48",
"sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086",
"sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e",
"sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133",
"sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5",
"sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484",
"sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee",
"sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5",
"sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68",
"sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a",
"sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf",
"sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99",
"sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8",
"sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85",
"sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19",
"sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc",
"sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a",
"sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1",
"sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317",
"sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c",
"sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631",
"sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d",
"sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652",
"sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5",
"sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e",
"sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b",
"sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8",
"sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476",
"sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706",
"sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563",
"sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237",
"sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b",
"sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083",
"sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180",
"sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425",
"sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e",
"sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f",
"sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725",
"sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183",
"sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab",
"sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774",
"sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725",
"sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e",
"sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5",
"sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d",
"sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290",
"sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44",
"sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed",
"sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4",
"sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba",
"sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12",
"sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"
"sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a",
"sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.2"
"version": "==0.3.0"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"requests": {
"hashes": [
"sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
"sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"markers": "python_version >= '3.8'",
"version": "==2.32.3"
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"shandy-sqlfmt": {
"hashes": [
@@ -600,12 +663,12 @@
},
"tinybird-cli": {
"hashes": [
"sha256:5ea2c759fff89d5a1104cdfdd5fd8e2bfde68445ea8635e1ef1031403e558863",
"sha256:7e855f9338b2e982237e25bb3ad9f0e36c9305ab546c01c68df6c31ad59b2e05"
"sha256:a1d0332d4c8f8ef16cedbed6e5a872d5f372604a6bdc6bb9872ebea8e33e0fda",
"sha256:f2c4a95f9e4f79cd7bdc39fef1ec093cba2289b7d6a43d888eff26f1b860c370"
],
"index": "pypi",
"markers": "python_version < '3.13' and python_version >= '3.9'",
"version": "==5.13.1"
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==5.21.1"
},
"toposort": {
"hashes": [
@@ -639,11 +702,11 @@
},
"typing-extensions": {
"hashes": [
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
],
"markers": "python_version < '3.13'",
"version": "==4.12.2"
"version": "==4.15.0"
},
"urllib3": {
"hashes": [
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
The latest version of Papermark is currently being supported with security updates.
## Reporting a Vulnerability
To report a vulnerability, send an email to security@papermark.com.
We will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure.
@@ -8,9 +8,8 @@ import { waitUntil } from "@vercel/functions";
import { hashToken } from "@/lib/api/auth/token";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { sendEmail } from "@/lib/resend";
import { sendEmail, subscribe, unsubscribe } from "@/lib/resend";
import { CustomUser } from "@/lib/types";
import { subscribe, unsubscribe } from "@/lib/unsend";
import EmailUpdated from "@/components/emails/email-updated";
@@ -0,0 +1,339 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { LogoCloud } from "@/components/shared/logo-cloud";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function EmailVerificationClient() {
const router = useRouter();
const codeInputRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState("");
const [emailLocked, setEmailLocked] = useState(false);
const [code, setCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isExpired, setIsExpired] = useState(false);
const [showEmailDeliveryNotice, setShowEmailDeliveryNotice] = useState(false);
// Check sessionStorage for pending verification email on mount
useEffect(() => {
try {
const pendingEmail = sessionStorage.getItem("pendingVerificationEmail");
if (pendingEmail) {
setEmail(pendingEmail);
setEmailLocked(true);
// Clear the stored email after reading
sessionStorage.removeItem("pendingVerificationEmail");
// Focus the code input
setTimeout(() => {
codeInputRef.current?.focus();
}, 100);
}
} catch {
// sessionStorage not available, user will need to enter email manually
}
}, []);
// Show email delivery notice after 5 seconds when waiting for verification
useEffect(() => {
if (!emailLocked) {
setShowEmailDeliveryNotice(false);
return;
}
const timer = setTimeout(() => {
setShowEmailDeliveryNotice(true);
}, 10000);
return () => clearTimeout(timer);
}, [emailLocked]);
// Code verification
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/auth/verify-code", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email.trim().toLowerCase(),
code: code.trim().toUpperCase(),
}),
});
const data = await response.json();
if (!response.ok) {
if (
response.status === 410 ||
response.status === 401 ||
data.error?.includes("expired") ||
data.error?.includes("Invalid code")
) {
setIsExpired(true);
setError("This code has expired or is invalid.");
} else if (response.status === 429) {
setError(
data.error || "Too many attempts. Please wait before trying again.",
);
} else {
setError(data.error || "Verification failed. Please try again.");
}
setIsLoading(false);
return;
}
// Redirect to the callback URL
if (data.callbackUrl) {
router.push(data.callbackUrl);
} else {
// No callback URL in response - stop loading and show error
setIsLoading(false);
setError("Unable to complete sign-in: missing callback URL. Please try again.");
}
} catch (err) {
setError("An error occurred. Please try again.");
setIsLoading(false);
}
};
// Show expired state
if (isExpired) {
return (
<div className="flex h-screen w-full flex-wrap">
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
<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)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
<span className="text-balance text-3xl font-semibold text-gray-900">
Code Expired
</span>
<h3 className="text-balance text-sm text-gray-800">
This login code has expired or has already been used.
</h3>
</div>
<div className="flex flex-col gap-4 px-4 pt-8 sm:px-12">
<Link href="/login">
<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">
Request a new code
</Button>
</Link>
</div>
</div>
</div>
<TestimonialSection />
</div>
);
}
return (
<div className="flex h-screen w-full flex-wrap">
{/* Left part */}
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
<div
className="absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
aria-hidden="true"
></div>
<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)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
<Link href="/">
<span className="text-balance text-3xl font-semibold text-gray-900">
Check your email
</span>
</Link>
<h3 className="text-balance text-sm text-gray-800">
{emailLocked ? (
<>
We sent a login code to{" "}
<span className="font-medium">{email}</span>
</>
) : (
"Enter your email and the code we sent you"
)}
</h3>
</div>
{/* Delayed email delivery notice */}
{showEmailDeliveryNotice && emailLocked && (
<div className="mx-4 mt-4 rounded-lg border border-orange-200 bg-orange-50 p-4 sm:mx-12">
<p className="text-sm text-orange-800">
Due to a recent Microsoft outage, we are experiencing delivery
issues with Outlook and Microsoft email accounts.
</p>
<p className="mt-2 text-sm text-orange-800">
Check your junk/spam and quarantine folders and ensure that{" "}
<a
href="mailto:system@papermark.com"
className="font-medium text-orange-600 underline hover:text-orange-700"
>
system@papermark.com
</a>{" "}
is on your allowed senders list.
</p>
</div>
)}
<form
className="flex flex-col gap-4 px-4 pt-8 sm:px-12"
onSubmit={handleSubmit}
>
{/* Only show email field if not locked from sessionStorage */}
{!emailLocked && (
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
value={email}
onChange={(e) => setEmail(e.target.value)}
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"
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="code">Verification Code</Label>
<Input
ref={codeInputRef}
id="code"
placeholder="Enter 10-character code"
type="text"
autoCapitalize="characters"
autoComplete="one-time-code"
autoCorrect="off"
disabled={isLoading}
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
maxLength={10}
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"
/>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
<Button
type="submit"
loading={isLoading}
disabled={isLoading || !email || code.length < 10}
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"
>
Verify
</Button>
</form>
<p className="mt-6 px-4 text-center text-sm text-muted-foreground sm:px-12">
Didn&apos;t receive a code?{" "}
<Link href="/login" className="text-gray-900 underline">
Try again
</Link>
</p>
<p className="mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12">
By clicking continue, you acknowledge that you have read and agree
to Papermark&apos;s{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
target="_blank"
className="underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
target="_blank"
className="underline"
>
Privacy Policy
</a>
.
</p>
</div>
</div>
<TestimonialSection />
</div>
);
}
function TestimonialSection() {
return (
<div className="relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2">
<div className="relative m-0 flex h-full min-h-[700px] w-full p-0">
<div
className="relative flex h-full w-full flex-col justify-between"
id="features"
>
{/* Testimonial top 2/3 */}
<div
className="flex w-full flex-col items-center justify-center"
style={{ height: "66.6666%" }}
>
{/* Image container */}
<div className="mb-4 h-64 w-80">
<img
className="h-full w-full rounded-2xl object-cover shadow-2xl"
src="/_static/testimonials/backtrace.jpeg"
alt="Backtrace Capital"
/>
</div>
{/* Text content */}
<div className="max-w-xl text-center">
<blockquote className="text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9">
<p>
&quot;We raised our 30M Fund with Papermark Data Rooms. Love
the customization, security and ease of use.&quot;
</p>
</blockquote>
<figcaption className="mt-4">
<div className="text-balance font-normal text-white">
Michael Münnix
</div>
<div className="text-balance font-light text-gray-400">
Partner, Backtrace Capital
</div>
</figcaption>
</div>
</div>
{/* White block with logos bottom 1/3, full width/height */}
<div
className="absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white"
style={{ height: "33.3333%" }}
>
<div className="mb-4 max-w-xl text-balance text-center font-semibold text-gray-900">
Trusted by teams at
</div>
<LogoCloud />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { Metadata } from "next";
import EmailVerificationClient from "./page-client";
const data = {
description: "Verify your login to Papermark",
title: "Verify Login | Papermark",
url: "/auth/email",
};
export const metadata: Metadata = {
metadataBase: new URL("https://www.papermark.com"),
title: data.title,
description: data.description,
openGraph: {
title: data.title,
description: data.description,
url: data.url,
siteName: "Papermark",
images: [
{
url: "/_static/meta-image.png",
width: 800,
height: 600,
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: data.title,
description: data.description,
creator: "@papermarkio",
images: ["/_static/meta-image.png"],
},
};
export default async function EmailVerificationPage() {
return <EmailVerificationClient />;
}
+17 -12
View File
@@ -1,8 +1,7 @@
"use client";
import { Metadata } from "next";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
@@ -24,6 +23,7 @@ import { Label } from "@/components/ui/label";
export default function Login() {
const { next } = useParams as { next?: string };
const router = useRouter();
const [lastUsed, setLastUsed] = useLastUsed();
const authMethods = ["google", "email", "linkedin", "passkey"] as const;
@@ -85,15 +85,22 @@ export default function Login() {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.ok && !res?.error) {
setEmail("");
setLastUsed("credentials");
setEmailButtonText("Email sent - check your inbox!");
toast.success("Email sent - check your inbox!");
// Store email in sessionStorage for the verification page
try {
sessionStorage.setItem(
"pendingVerificationEmail",
emailValidation.data,
);
} catch {
// sessionStorage not available, verification page will show email input
}
router.push("/auth/email");
} else {
setEmailButtonText("Error sending email - try again?");
toast.error("Error sending email - try again?");
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
>
@@ -145,9 +152,7 @@ export default function Login() {
signIn("google", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.status) {
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
loading={clickedMethod === "google"}
@@ -169,9 +174,7 @@ export default function Login() {
signIn("linkedin", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.status) {
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
loading={clickedMethod === "linkedin"}
@@ -192,6 +195,8 @@ export default function Login() {
setClickedMethod("passkey");
signInWithPasskey({
tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,
}).then(() => {
setClickedMethod(undefined);
});
}}
variant="outline"
+8 -1
View File
@@ -1,5 +1,7 @@
import { Metadata } from "next";
import { GTMComponent } from "@/components/gtm-component";
import LoginClient from "./page-client";
const data = {
@@ -37,5 +39,10 @@ export const metadata: Metadata = {
};
export default function LoginPage() {
return <LoginClient />;
return (
<>
<GTMComponent />
<LoginClient />
</>
);
}
+4 -177
View File
@@ -1,179 +1,6 @@
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import NotFound from "@/pages/404";
import { generateChecksum } from "@/lib/utils/generate-checksum";
import { LogoCloud } from "@/components/shared/logo-cloud";
import { Button } from "@/components/ui/button";
const data = {
description: "Verify login to Papermark",
title: "Verify | Papermark",
url: "/verify",
};
export const metadata: Metadata = {
metadataBase: new URL("https://www.papermark.com"),
title: data.title,
description: data.description,
openGraph: {
title: data.title,
description: data.description,
url: data.url,
siteName: "Papermark",
images: [
{
url: "/_static/meta-image.png",
width: 800,
height: 600,
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: data.title,
description: data.description,
creator: "@papermarkio",
images: ["/_static/meta-image.png"],
},
};
export default function VerifyPage({
searchParams,
}: {
searchParams: { verification_url?: string; checksum?: string };
}) {
const { verification_url, checksum } = searchParams;
if (!verification_url || !checksum) {
return <NotFound />;
}
// Server-side validation
const isValidVerificationUrl = (url: string, checksum: string): boolean => {
try {
const urlObj = new URL(url);
if (urlObj.origin !== process.env.NEXTAUTH_URL) return false;
const expectedChecksum = generateChecksum(url);
return checksum === expectedChecksum;
} catch {
return false;
}
};
if (!isValidVerificationUrl(verification_url, checksum)) {
return <NotFound />;
}
return (
<div className="flex h-screen w-full flex-wrap">
{/* Left part */}
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
<div
className="absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
aria-hidden="true"
></div>
<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)]">
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
<img
src="/_static/papermark-logo.svg"
alt="Papermark Logo"
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
/>
<Link href="/">
<span className="text-balance text-3xl font-semibold text-gray-900">
Verify your login
</span>
</Link>
<h3 className="text-balance text-sm text-gray-800">
Share documents. Not attachments.
</h3>
</div>
<div className="flex flex-col gap-4 px-4 pt-8 sm:px-12">
<div className="relative">
<Link href={verification_url}>
<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">
Verify email
</Button>
</Link>
</div>
</div>
<p className="mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12">
By clicking continue, you acknowledge that you have read and agree
to Papermark&apos;s{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
target="_blank"
className="underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
target="_blank"
className="underline"
>
Privacy Policy
</a>
.
</p>
</div>
</div>
<div className="relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2">
<div className="relative m-0 flex h-full min-h-[700px] w-full p-0">
<div
className="relative flex h-full w-full flex-col justify-between"
id="features"
>
{/* Testimonial top 2/3 */}
<div
className="flex w-full flex-col items-center justify-center"
style={{ height: "66.6666%" }}
>
{/* Image container */}
<div className="mb-4 h-64 w-80">
<img
className="h-full w-full rounded-2xl object-cover shadow-2xl"
src="/_static/testimonials/backtrace.jpeg"
alt="Backtrace Capital"
/>
</div>
{/* Text content */}
<div className="max-w-xl text-center">
<blockquote className="text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9">
<p>
&quot;We raised our 30M Fund with Papermark Data Rooms.
Love the customization, security and ease of use.&quot;
</p>
</blockquote>
<figcaption className="mt-4">
<div className="text-balance font-normal text-white">
Michael Münnix
</div>
<div className="text-balance font-light text-gray-400">
Partner, Backtrace Capital
</div>
</figcaption>
</div>
</div>
{/* White block with logos bottom 1/3, full width/height */}
<div
className="absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white"
style={{ height: "33.3333%" }}
>
<div className="mb-4 max-w-xl text-balance text-center font-semibold text-gray-900">
Trusted by teams at
</div>
<LogoCloud />
</div>
</div>
</div>
</div>
</div>
);
// Legacy verify page - redirect to new auth email flow
export default async function VerifyPage() {
redirect("/auth/email");
}
+1
View File
@@ -0,0 +1 @@
../ee/LICENSE.md
+1
View File
@@ -0,0 +1 @@
../ee/README.md
@@ -0,0 +1,153 @@
import { NextRequest } from "next/server";
import { generateChatTitle } from "@/ee/features/ai/lib/chat/generate-chat-title";
import { getFilteredDataroomDocumentIds } from "@/ee/features/ai/lib/chat/get-filtered-dataroom-document-ids";
import { sendMessage } from "@/ee/features/ai/lib/chat/send-message";
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
import { sendMessageSchema } from "@/ee/features/ai/schemas/chat";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/chat/[chatId]/messages
* Send a message and get streaming response
*/
export async function POST(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const body = await req.json();
const validation = sendMessageSchema.safeParse(body);
if (!validation.success) {
return new Response(
JSON.stringify({
error: "Invalid request body",
details: validation.error,
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const { content, filterDocumentId, filterDataroomDocumentIds } =
validation.data;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const viewerId = searchParams.get("viewerId");
let userId: string | undefined;
if (session) {
userId = (session.user as CustomUser).id;
}
// Validate access
const hasAccess = await validateChatAccess({
chatId,
userId,
viewerId: viewerId || undefined,
});
if (!hasAccess) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Get chat details
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
messages: {
take: 1,
},
},
});
if (!chat) {
return new Response(JSON.stringify({ error: "Chat not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return new Response(
JSON.stringify({
error: "AI features are not available for this team",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
if (!chat.vectorStoreId) {
return new Response(
JSON.stringify({
error: "Vector store not available. Please index documents first.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (
filterDataroomDocumentIds &&
filterDataroomDocumentIds.length > 0 &&
!chat.dataroomId
) {
return new Response(
JSON.stringify({
error:
"Dataroom document filters are only allowed for dataroom chats",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Generate title from first message if not set
if (!chat.title && chat.messages.length === 0) {
const title = await generateChatTitle(content);
await prisma.chat.update({
where: { id: chatId },
data: { title },
});
}
// Get filtered dataroom document IDs based on permissions
let filteredDataroomDocumentIds: string[] | undefined;
if (chat.dataroomId && chat.linkId) {
filteredDataroomDocumentIds = await getFilteredDataroomDocumentIds(
chat.dataroomId,
chat.linkId,
);
}
// Send message and get streaming response
const result = await sendMessage({
chatId,
content,
vectorStoreId: chat.vectorStoreId,
filteredDataroomDocumentIds,
filterDocumentId,
userSelectedDataroomDocumentIds: filterDataroomDocumentIds,
});
return result.toTextStreamResponse();
} catch (error) {
console.error("Error sending message:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
+146
View File
@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from "next/server";
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* GET /api/ai/chat/[chatId]
* Get chat details with messages
*/
export async function GET(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const viewerId = searchParams.get("viewerId");
let userId: string | undefined;
if (session) {
userId = (session.user as CustomUser).id;
}
// Validate access
const hasAccess = await validateChatAccess({
chatId,
userId,
viewerId: viewerId || undefined,
});
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch chat with messages
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
messages: {
orderBy: { createdAt: "asc" },
},
document: {
select: {
id: true,
name: true,
},
},
dataroom: {
select: {
id: true,
name: true,
},
},
},
});
if (!chat) {
return NextResponse.json({ error: "Chat not found" }, { status: 404 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
return NextResponse.json(chat);
} catch (error) {
console.error("Error fetching chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/ai/chat/[chatId]
* Delete a chat
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Validate ownership
const hasAccess = await validateChatAccess({
chatId,
userId,
});
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify user owns this chat
const chat = await prisma.chat.findUnique({
where: { id: chatId },
});
if (!chat || chat.userId !== userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Delete chat (messages will cascade)
await prisma.chat.delete({
where: { id: chatId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+590
View File
@@ -0,0 +1,590 @@
import { NextRequest, NextResponse } from "next/server";
import { createChat } from "@/ee/features/ai/lib/chat/create-chat";
import { createChatSchema } from "@/ee/features/ai/schemas/chat";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/chat
* Create a new chat session
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const validation = createChatSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid request body", details: validation.error },
{ status: 400 },
);
}
const { documentId, dataroomId, linkId, viewId, title, viewerId } =
validation.data;
// Check for either internal user or external viewer authentication
const session = await getServerSession(authOptions);
let userId: string | undefined;
let teamId: string;
// Internal user flow
if (session) {
userId = (session.user as CustomUser).id;
// Get team ID from document or dataroom
if (dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: dataroomId },
select: {
teamId: true,
agentsEnabled: true,
vectorStoreId: true,
team: {
select: {
agentsEnabled: true,
},
},
},
});
if (!dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
// Check if team has AI enabled first
if (!dataroom.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!dataroom.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
teamId = dataroom.teamId;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
} else if (documentId) {
const document = await prisma.document.findUnique({
where: { id: documentId },
select: {
teamId: true,
agentsEnabled: true,
team: {
select: {
agentsEnabled: true,
},
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
// Check if team has AI enabled first
if (!document.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!document.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
teamId = document.teamId;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
} else {
return NextResponse.json(
{ error: "Either documentId or dataroomId is required" },
{ status: 400 },
);
}
}
// External viewer flow
else if (linkId && viewId && viewerId) {
// Verify dataroom session if this is a dataroom link
if (dataroomId) {
const dataroomSession = await verifyDataroomSession(
req,
linkId,
dataroomId,
);
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
// Verify link has AI enabled
const link = await prisma.link.findUnique({
where: { id: linkId, dataroomId },
select: {
enableAIAgents: true,
isArchived: true,
dataroom: { select: { teamId: true, agentsEnabled: true } },
team: { select: { agentsEnabled: true } },
},
});
if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 },
);
}
if (link?.isArchived) {
return NextResponse.json(
{ error: "Link is archived" },
{ status: 403 },
);
}
if (!link?.enableAIAgents) {
return NextResponse.json(
{ error: "AI agents are not enabled for this link" },
{ status: 403 },
);
}
if (!link?.dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
if (!link?.dataroom?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
if (!link?.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled" },
{ status: 403 },
);
}
teamId = link?.dataroom?.teamId;
} else if (documentId) {
// Verify link access for document
const link = await prisma.link.findUnique({
where: { id: linkId, documentId },
select: {
enableAIAgents: true,
isArchived: true,
document: { select: { teamId: true, agentsEnabled: true } },
team: { select: { agentsEnabled: true } },
},
});
if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 },
);
}
if (link?.isArchived) {
return NextResponse.json(
{ error: "Link is archived" },
{ status: 403 },
);
}
// Check if link has AI enabled
if (!link.enableAIAgents) {
return NextResponse.json(
{ error: "AI agents are not enabled for this link" },
{ status: 403 },
);
}
if (!link.document?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
if (!link?.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled" },
{ status: 403 },
);
}
if (!link.document || !link.document?.teamId) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 400 },
);
}
teamId = link.document?.teamId;
} else {
return NextResponse.json(
{ error: "Either documentId or dataroomId is required" },
{ status: 400 },
);
}
// Verify viewer exists
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId, teamId },
});
if (!viewer) {
return NextResponse.json(
{ error: "Viewer not found" },
{ status: 404 },
);
}
} else {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get vector store ID
let vectorStoreId: string | undefined;
if (dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: dataroomId },
select: { vectorStoreId: true },
});
vectorStoreId = dataroom?.vectorStoreId || undefined;
} else if (documentId) {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { vectorStoreId: true },
});
vectorStoreId = team?.vectorStoreId || undefined;
}
// Create the chat
const chat = await createChat({
teamId,
documentId,
dataroomId,
linkId,
viewId,
userId,
viewerId,
vectorStoreId,
title,
});
return NextResponse.json(chat, { status: 201 });
} catch (error) {
console.error("Error creating chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* GET /api/ai/chat
* List chats with filters
* Supports both internal users (via session) and external viewers (via viewerId param)
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const chatListQuerySchema = z.object({
viewerId: z.string().cuid().optional(),
teamId: z.string().cuid().optional(),
documentId: z.string().cuid().optional(),
dataroomId: z.string().cuid().optional(),
linkId: z.string().cuid().optional(),
viewId: z.string().cuid().optional(),
});
const queryObj = {
viewerId: searchParams.get("viewerId") ?? undefined,
teamId: searchParams.get("teamId") ?? undefined,
documentId: searchParams.get("documentId") ?? undefined,
dataroomId: searchParams.get("dataroomId") ?? undefined,
linkId: searchParams.get("linkId") ?? undefined,
viewId: searchParams.get("viewId") ?? undefined,
};
const { viewerId, teamId, documentId, dataroomId, linkId, viewId } =
chatListQuerySchema.parse(queryObj);
// Combined flow: logged-in user with viewerId
// This handles the case where a team member is also viewing as a viewer
if (session && viewerId && (dataroomId || documentId)) {
const userId = (session.user as CustomUser).id;
// Verify viewer exists
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId },
select: {
teamId: true,
},
});
if (!viewer) {
return NextResponse.json(
{ error: "Viewer not found" },
{ status: 404 },
);
}
// Verify user is member of team that the viewer is associated with
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId: viewer.teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: viewer.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Build where clause - find chats with this userId OR viewerId
const baseWhere: any = {};
if (documentId) {
baseWhere.documentId = documentId;
}
if (dataroomId) {
baseWhere.dataroomId = dataroomId;
}
if (linkId) {
baseWhere.linkId = linkId;
}
// Fetch chats that belong to either userId or viewerId
const chats = await prisma.chat.findMany({
where: {
...baseWhere,
OR: [{ userId }, { viewerId }],
},
orderBy: {
createdAt: "desc",
},
take: 50,
});
return NextResponse.json(chats);
}
// External viewer flow (not logged in)
if (viewerId && linkId && viewId) {
// Build where clause for viewer's chats
const where: any = {
viewerId,
linkId,
};
if (dataroomId) {
// verify dataroom session
const dataroomSession = await verifyDataroomSession(
req,
linkId,
dataroomId,
);
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
where.dataroomId = dataroomId;
}
if (documentId) {
// verify document session
const view = await prisma.view.findUnique({
where: { id: viewId, linkId, documentId },
select: {
viewerId: true,
},
});
if (!view || view.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
where.documentId = documentId;
}
// Fetch viewer's chats
const chats = await prisma.chat.findMany({
where,
orderBy: {
createdAt: "desc",
},
take: 50,
select: {
id: true,
title: true,
createdAt: true,
lastMessageAt: true,
},
});
return NextResponse.json(chats);
}
// Internal user flow (logged in, no viewerId)
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
if (!teamId) {
return NextResponse.json(
{ error: "teamId is required" },
{ status: 400 },
);
}
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Build where clause
const where: any = {
teamId,
userId, // Only show user's own chats
};
if (documentId) {
where.documentId = documentId;
}
if (dataroomId) {
where.dataroomId = dataroomId;
}
// Fetch chats
const chats = await prisma.chat.findMany({
where,
orderBy: {
createdAt: "desc",
},
take: 50,
});
return NextResponse.json(chats);
} catch (error) {
console.error("Error fetching chats:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { runs } from "@trigger.dev/sdk/v3";
import { getServerSession } from "next-auth";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* GET /api/ai/store/runs/[runId]
* Get the status of a Trigger.dev run for polling
*/
export async function GET(
req: NextRequest,
{ params }: { params: { runId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { runId } = params;
const run = await runs.retrieve(runId);
// Verify the user is authorized to access this run
const runTeamId = run.metadata?.teamId as string | undefined;
const userTeams = await prisma.userTeam.findMany({
where: { userId: (session.user as CustomUser).id },
select: { teamId: true },
});
const userTeamIds = userTeams.map((ut) => ut.teamId);
if (!runTeamId || !userTeamIds.includes(runTeamId)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
return NextResponse.json({
id: run.id,
status: run.status,
metadata: run.metadata,
isCompleted: run.isCompleted,
isFailed: run.isFailed,
output: run.output,
});
} catch (error) {
console.error("Error retrieving run status:", error);
return NextResponse.json(
{ error: "Failed to retrieve run status" },
{ status: 500 },
);
}
}
@@ -0,0 +1,253 @@
import { NextRequest, NextResponse } from "next/server";
import {
SUPPORTED_AI_CONTENT_TYPES,
addFileToVectorStoreTask,
processDocumentForAITask,
} from "@/ee/features/ai/lib/trigger";
import { createDataroomVectorStore } from "@/ee/features/ai/lib/vector-stores/create-dataroom-vector-store";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/store/teams/[teamId]/datarooms/[dataroomId]
* Index all documents in a dataroom into its vector store
* Returns batch runIds for status tracking via polling
*/
export async function POST(
req: NextRequest,
{ params }: { params: { dataroomId: string; teamId: string } },
) {
try {
const { dataroomId, teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get dataroom and documents
const dataroom = await prisma.dataroom.findUnique({
where: {
id: dataroomId,
teamId,
},
include: {
team: {
select: {
agentsEnabled: true,
},
},
documents: {
include: {
document: {
select: {
id: true,
name: true,
versions: {
where: { isPrimary: true },
take: 1,
select: {
id: true,
fileId: true,
contentType: true,
storageType: true,
originalFile: true,
file: true,
},
},
},
},
},
},
},
});
if (!dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
// Check if team has AI enabled
if (!dataroom.team.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!dataroom.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
// Create or get vector store
let vectorStoreId = dataroom.vectorStoreId;
if (!vectorStoreId) {
vectorStoreId = await createDataroomVectorStore({
dataroomId,
teamId: dataroom.teamId,
name: dataroom.name,
});
// Update dataroom with vector store ID
await prisma.dataroom.update({
where: { id: dataroomId },
data: { vectorStoreId },
});
}
// Track triggered runs for status polling
const triggeredRuns: Array<{
documentId: string;
documentName: string;
runId: string;
}> = [];
const skippedDocuments: string[] = [];
const errors: string[] = [];
// Trigger processing for each document
for (const dataroomDoc of dataroom.documents) {
const document = dataroomDoc.document;
const primaryVersion = document.versions[0];
if (!primaryVersion) {
errors.push(`No primary version for document: ${document.name}`);
continue;
}
// Skip if already indexed in this dataroom
if (dataroomDoc.vectorStoreFileId) {
skippedDocuments.push(document.name);
continue;
}
// Check if document type is supported
const contentType = primaryVersion.contentType || "";
if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {
errors.push(
`Unsupported file type for document: ${document.name} (${contentType})`,
);
continue;
}
try {
// Determine file path
const filePath =
primaryVersion.originalFile && contentType !== "application/pdf"
? primaryVersion.originalFile
: primaryVersion.file;
const fileMetadata = {
teamId: dataroom.teamId,
documentId: document.id,
documentName: document.name,
versionId: primaryVersion.id,
dataroomId: dataroom.id,
dataroomDocumentId: dataroomDoc.id,
dataroomFolderId: dataroomDoc.folderId || "root",
};
let handle;
// If document already has fileId, just add to vector store
if (primaryVersion.fileId) {
handle = await addFileToVectorStoreTask.trigger({
fileId: primaryVersion.fileId,
vectorStoreId,
metadata: fileMetadata,
});
} else {
// Trigger full processing
handle = await processDocumentForAITask.trigger(
{
documentId: document.id,
documentVersionId: primaryVersion.id,
teamId: dataroom.teamId,
vectorStoreId,
documentName: document.name,
filePath,
storageType: primaryVersion.storageType,
contentType,
metadata: fileMetadata,
},
{
idempotencyKey: `ai-index-dataroom-${dataroom.id}-${primaryVersion.id}`,
tags: [
`team_${dataroom.teamId}`,
`dataroom_${dataroom.id}`,
`document_${document.id}`,
`version_${primaryVersion.id}`,
],
},
);
}
triggeredRuns.push({
documentId: document.id,
documentName: document.name,
runId: handle.id,
});
} catch (error) {
console.error(
`Error triggering processing for ${document.name}:`,
error,
);
errors.push(
`Failed to trigger processing for: ${document.name} - ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
return NextResponse.json({
success: true,
vectorStoreId,
totalDocuments: dataroom.documents.length,
triggeredCount: triggeredRuns.length,
skippedCount: skippedDocuments.length,
runs: triggeredRuns,
skipped: skippedDocuments.length > 0 ? skippedDocuments : undefined,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error) {
console.error("Error indexing dataroom:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,300 @@
import { NextRequest, NextResponse } from "next/server";
import {
addFileToVectorStoreTask,
processDocumentForAITask,
SUPPORTED_AI_CONTENT_TYPES,
} from "@/ee/features/ai/lib/trigger";
import { createTeamVectorStore } from "@/ee/features/ai/lib/vector-stores/create-team-vector-store";
import { removeFileFromVectorStore } from "@/ee/features/ai/lib/vector-stores/remove-file-from-vector-store";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/store/teams/[teamId]/documents/[documentId]
* Index a single document into the team vector store
* Returns runId for status tracking via polling
*/
export async function POST(
req: NextRequest,
{ params }: { params: { documentId: string; teamId: string } },
) {
try {
const { documentId, teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get document and version
const document = await prisma.document.findUnique({
where: { id: documentId, teamId },
include: {
team: {
select: {
agentsEnabled: true,
vectorStoreId: true,
name: true,
},
},
versions: {
where: { isPrimary: true },
take: 1,
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
// Check if team has AI enabled
if (!document.team.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!document.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
const primaryVersion = document.versions[0];
if (!primaryVersion) {
return NextResponse.json(
{ error: "No primary version found for this document" },
{ status: 400 },
);
}
// Create or get team vector store
let vectorStoreId = document.team.vectorStoreId;
if (!vectorStoreId) {
vectorStoreId = await createTeamVectorStore(
document.teamId,
document.team.name,
);
// Update team with vector store ID
await prisma.team.update({
where: { id: document.teamId },
data: { vectorStoreId },
});
}
// Check if document type is supported
const contentType = primaryVersion.contentType || "";
if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {
return NextResponse.json(
{
error: `Unsupported file type: ${contentType}. Supported types: PDF, Excel, and images (JPEG, PNG, WebP).`,
},
{ status: 400 },
);
}
// Determine file path - use originalFile for non-PDF, file for PDF
const filePath =
primaryVersion.originalFile && contentType !== "application/pdf"
? primaryVersion.originalFile
: primaryVersion.file;
const fileMetadata = {
teamId: document.teamId,
documentId: document.id,
documentName: document.name,
versionId: primaryVersion.id,
folderId: document.folderId || "root",
};
// Check if document already has a fileId - use lightweight task
if (primaryVersion.fileId) {
// Document already processed, just add to vector store
const handle = await addFileToVectorStoreTask.trigger({
fileId: primaryVersion.fileId,
vectorStoreId,
metadata: fileMetadata,
});
return NextResponse.json({
success: true,
runId: handle.id,
isReprocessing: false,
});
}
// Trigger full document processing task
const handle = await processDocumentForAITask.trigger(
{
documentId: document.id,
documentVersionId: primaryVersion.id,
teamId: document.teamId,
vectorStoreId,
documentName: document.name,
filePath,
storageType: primaryVersion.storageType,
contentType,
metadata: fileMetadata,
},
{
idempotencyKey: `ai-index-${document.teamId}-${primaryVersion.id}`,
tags: [
`team_${document.teamId}`,
`document_${document.id}`,
`version_${primaryVersion.id}`,
],
},
);
return NextResponse.json({
success: true,
runId: handle.id,
isReprocessing: true,
});
} catch (error) {
console.error("Error indexing document:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/ai/store/teams/[teamId]/documents/[documentId]
* Remove a document from the team vector store
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { documentId: string; teamId: string } },
) {
try {
const { documentId, teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get document and verify user access
const document = await prisma.document.findUnique({
where: { id: documentId, teamId },
include: {
team: {
select: {
vectorStoreId: true,
},
},
versions: {
where: { isPrimary: true },
take: 1,
select: {
id: true,
vectorStoreFileId: true,
},
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: document.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
const primaryVersion = document.versions[0];
const vectorStoreId = document.team.vectorStoreId;
if (!primaryVersion?.vectorStoreFileId || !vectorStoreId) {
return NextResponse.json(
{ error: "Document is not indexed" },
{ status: 400 },
);
}
// Remove file from vector store
await removeFileFromVectorStore(
vectorStoreId,
primaryVersion.vectorStoreFileId,
);
// Clear vector store file ID
await prisma.documentVersion.update({
where: { id: primaryVersion.id },
data: { vectorStoreFileId: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error removing document from vector store:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { getVectorStoreInfo } from "@/ee/features/ai/lib/vector-stores/get-vector-store-info";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* GET /api/ai/store/teams/[teamId]
* Get team vector store information
*/
export async function GET(
req: NextRequest,
{ params }: { params: { teamId: string } },
) {
try {
const { teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findFirst({
where: {
userId,
teamId,
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get team with vector store ID
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
vectorStoreId: true,
agentsEnabled: true,
},
});
if (!team) {
return NextResponse.json({ error: "Team not found" }, { status: 404 });
}
if (!team.vectorStoreId) {
return NextResponse.json({
agentsEnabled: team.agentsEnabled,
vectorStoreId: null,
info: null,
});
}
// Get vector store info
const info = await getVectorStoreInfo(team.vectorStoreId);
return NextResponse.json({
agentsEnabled: team.agentsEnabled,
vectorStoreId: team.vectorStoreId,
info,
});
} catch (error) {
console.error("Error fetching team vector store info:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import prisma from "@/lib/prisma";
// Validation schema for query parameters
const visitorFAQParamsSchema = z.object({
linkId: z.string().cuid("Invalid link ID format"),
dataroomId: z.string().cuid("Invalid dataroom ID format"),
documentId: z.string().cuid("Invalid document ID format").nullish(), // This is actually dataroomDocumentId
});
export interface VisitorFAQResponse {
id: string;
editedQuestion: string;
answer: string;
documentPageNumber?: number;
documentVersionNumber?: number;
createdAt: string;
document?: {
name: string;
};
}
// GET /api/faqs?linkId=xxx&dataroomId=xxx - List published FAQs for visitors
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
// Validate query parameters
const paramValidation = visitorFAQParamsSchema.safeParse({
linkId: searchParams.get("linkId"),
dataroomId: searchParams.get("dataroomId"),
documentId: searchParams.get("documentId"), // This is actually dataroomDocumentId
});
if (!paramValidation.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: paramValidation.error.errors[0]?.message,
},
{ status: 400 },
);
}
const { linkId, dataroomId, documentId } = paramValidation.data;
// Verify dataroom session
const session = await verifyDataroomSession(req, linkId, dataroomId);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized - invalid or expired session" },
{ status: 401 },
);
}
// Build where clause based on visibility filters
const whereClause: any = {
dataroomId,
status: "PUBLISHED",
};
// Apply visibility filters
const visibilityFilters: any[] = [
{ visibilityMode: "PUBLIC_DATAROOM" }, // Always include dataroom-wide FAQs
];
if (linkId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_LINK",
linkId: linkId,
});
}
if (documentId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_DOCUMENT",
dataroomDocumentId: documentId,
});
}
whereClause.OR = visibilityFilters;
// Fetch published FAQs
const faqs = await prisma.dataroomFaqItem.findMany({
where: whereClause,
select: {
id: true,
editedQuestion: true,
answer: true,
documentPageNumber: true,
documentVersionNumber: true,
createdAt: true,
dataroomDocument: {
select: {
document: {
select: {
name: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
// Format response
const response: VisitorFAQResponse[] = faqs.map((faq: any) => ({
id: faq.id,
editedQuestion: faq.editedQuestion,
answer: faq.answer,
documentPageNumber: faq.documentPageNumber || undefined,
documentVersionNumber: faq.documentVersionNumber || undefined,
createdAt: faq.createdAt.toISOString(),
document: faq.dataroomDocument?.document
? {
name: faq.dataroomDocument.document.name,
}
: undefined,
}));
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching visitor FAQs:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,396 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { WorkflowEngine } from "@/ee/features/workflows/lib/engine";
import {
AccessRequestSchema,
VerifyEmailRequestSchema,
} from "@/ee/features/workflows/lib/types";
import { ipAddress, waitUntil } from "@vercel/functions";
import { z } from "zod";
import { createDataroomSession } from "@/lib/auth/dataroom-auth";
import { createLinkSession } from "@/lib/auth/link-session";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
// POST /app/(ee)/api/workflow-entry/domains/[domain]/[slug]/[action]
// where action is "verify" or "access"
export async function POST(
req: NextRequest,
{ params }: { params: { domainSlug: string[] } },
) {
try {
const domainSlug = params.domainSlug;
if (!domainSlug || domainSlug.length < 3) {
return NextResponse.json(
{ error: "Invalid URL format. Expected: domain/slug/action" },
{ status: 400 },
);
}
const domain = domainSlug[0];
const slug = domainSlug[1];
const action = domainSlug[2]; // "verify" or "access"
if (action !== "verify" && action !== "access") {
return NextResponse.json(
{ error: "Invalid action. Must be 'verify' or 'access'" },
{ status: 400 },
);
}
// Find workflow by entry link's domain and slug
const link = await prisma.link.findUnique({
where: {
domainSlug_slug: {
domainSlug: domain,
slug: slug,
},
linkType: "WORKFLOW_LINK",
},
select: {
id: true,
isArchived: true,
deletedAt: true,
workflow: {
select: {
id: true,
isActive: true,
name: true,
teamId: true,
entryLinkId: true,
},
},
},
});
if (!link || !link.workflow) {
return NextResponse.json(
{ error: "Workflow entry link not found" },
{ status: 404 },
);
}
if (!link.workflow.isActive) {
return NextResponse.json(
{ error: "This workflow is currently inactive" },
{ status: 403 },
);
}
if (link.isArchived || link.deletedAt) {
return NextResponse.json(
{ error: "This link is no longer available" },
{ status: 404 },
);
}
// Handle verify action
if (action === "verify") {
return handleVerify(req, link);
}
// Handle access action
if (action === "access") {
return handleAccess(req, link);
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
} catch (error) {
console.error("Error in workflow entry endpoint:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// Handle verify (send OTP)
async function handleVerify(req: NextRequest, link: any) {
const body = await req.json();
// Validate request body
const validation = VerifyEmailRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 },
);
}
const { email } = validation.data;
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
`workflow-otp:${link.id}:${email}`,
);
if (!emailLimitSuccess) {
return NextResponse.json(
{
error:
"Please wait before requesting another code. Try again in 30 seconds.",
},
{ status: 429 },
);
}
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
const ipAddressValue = ipAddress(req);
const { success } = await ratelimit(10, "1 m").limit(
`workflow-otp:${ipAddressValue}`,
);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 },
);
}
// Delete any existing OTP codes for this workflow entry + email
await prisma.verificationToken.deleteMany({
where: {
identifier: `workflow-otp:${link.id}:${email}`,
},
});
// Generate and store OTP
const otpCode = generateOTP();
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes
await prisma.verificationToken.create({
data: {
token: otpCode,
identifier: `workflow-otp:${link.id}:${email}`,
expires: expiresAt,
},
});
// Send OTP email
waitUntil(
sendOtpVerificationEmail(email, otpCode, false, link.workflow.teamId),
);
return NextResponse.json({
success: true,
message: "Verification code sent to your email",
});
}
// Handle access (verify OTP and execute workflow)
async function handleAccess(req: NextRequest, link: any) {
const body = await req.json();
// Validate request body
const validation = AccessRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid request data" },
{ status: 400 },
);
}
const { email, code } = validation.data;
// Rate limiting
const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;
const { success } = await ratelimit(10, "1 m").limit(
`workflow-verify:${ipAddressValue}`,
);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 },
);
}
// Verify OTP code
const verification = await prisma.verificationToken.findUnique({
where: {
token: code,
identifier: `workflow-otp:${link.id}:${email}`,
},
});
if (!verification) {
return NextResponse.json(
{ error: "Invalid verification code", resetVerification: true },
{ status: 401 },
);
}
// Check OTP expiration
if (Date.now() > verification.expires.getTime()) {
await prisma.verificationToken.delete({
where: { token: code },
});
return NextResponse.json(
{ error: "Verification code expired", resetVerification: true },
{ status: 401 },
);
}
// Delete OTP after successful verification
await prisma.verificationToken.delete({
where: { token: code },
});
// Execute workflow engine
const engine = new WorkflowEngine();
const userAgent = req.headers.get("user-agent") ?? "unknown";
const referrer = req.headers.get("referer") ?? undefined;
const executionResult = await engine.execute(link.workflow.entryLinkId, {
visitorEmail: email,
visitorIp: ipAddressValue,
userAgent,
referrer,
});
if (!executionResult.success) {
return NextResponse.json(
{ error: executionResult.error || "Workflow execution failed" },
{ status: 400 },
);
}
// Find or create viewer
let viewer = await prisma.viewer.findUnique({
where: {
teamId_email: {
teamId: link.workflow.teamId,
email: email,
},
},
select: { id: true },
});
if (!viewer) {
viewer = await prisma.viewer.create({
data: {
email: email,
verified: true,
teamId: link.workflow.teamId,
},
select: { id: true },
});
}
// Create a view record for the workflow execution
const view = await prisma.view.create({
data: {
linkId: link.id,
viewerEmail: email,
viewerId: viewer.id,
verified: true,
teamId: link.workflow.teamId,
documentId: executionResult.targetDocumentId ?? undefined,
dataroomId: executionResult.targetDataroomId ?? undefined,
viewType:
executionResult.targetLinkType === "DATAROOM_LINK"
? "DATAROOM_VIEW"
: "DOCUMENT_VIEW",
},
select: { id: true },
});
// Create link session for the target link
const { token: sessionToken, expiresAt } = await createLinkSession(
executionResult.targetLinkId!,
executionResult.targetLinkType!,
view.id,
email,
ipAddressValue,
userAgent,
true, // verified
viewer.id, // viewerId
executionResult.targetDocumentId,
executionResult.targetDataroomId,
);
// Parse target URL safely with fallback
let targetPath = "/";
let cookieFlagId = executionResult.targetLinkId;
if (executionResult.targetUrl) {
try {
const parsedUrl = new URL(executionResult.targetUrl);
targetPath = parsedUrl.pathname;
const pathSegment = parsedUrl.pathname.split("/").pop();
if (pathSegment) {
cookieFlagId = pathSegment;
}
} catch (error) {
console.error("Failed to parse target URL, using fallback values");
}
}
// Set link session cookie (httpOnly)
cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(expiresAt),
path: "/",
});
// Set client-readable flag cookie for auto-login detection
const flagCookieId = `pm_link_flag_${cookieFlagId}`;
cookies().set(flagCookieId, "true", {
httpOnly: false, // Client-readable
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(expiresAt),
path: targetPath,
});
// If routing to a dataroom, also create and set dataroom session cookie
if (
executionResult.targetLinkType === "DATAROOM_LINK" &&
executionResult.targetDataroomId &&
viewer
) {
const dataroomSession = await createDataroomSession(
executionResult.targetDataroomId,
executionResult.targetLinkId!,
view.id,
ipAddressValue,
true, // verified
viewer.id,
);
cookies().set(
`pm_drs_${executionResult.targetLinkId}`,
dataroomSession.token,
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(dataroomSession.expiresAt),
path: "/",
},
);
// Set client-readable flag cookie for dataroom
const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;
cookies().set(dataroomFlagId, "true", {
httpOnly: false, // Client-readable
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(dataroomSession.expiresAt),
path: targetPath,
});
}
return NextResponse.json({
success: true,
targetUrl: executionResult.targetUrl,
targetLinkId: executionResult.targetLinkId,
targetLinkType: executionResult.targetLinkType,
});
}
@@ -0,0 +1,285 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { WorkflowEngine } from "@/ee/features/workflows/lib/engine";
import { AccessRequestSchema } from "@/ee/features/workflows/lib/types";
import { ipAddress } from "@vercel/functions";
import { z } from "zod";
import { createDataroomSession } from "@/lib/auth/dataroom-auth";
import { createLinkSession } from "@/lib/auth/link-session";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { LOCALHOST_IP } from "@/lib/utils/geo";
// POST /app/(ee)/api/workflow-entry/[entryLinkId]/access - Verify OTP and execute workflow
export async function POST(
req: NextRequest,
{ params }: { params: { entryLinkId: string } },
) {
try {
const { entryLinkId } = params;
const body = await req.json();
// Validate entryLinkId format
const linkIdValidation = z.string().cuid().safeParse(entryLinkId);
if (!linkIdValidation.success) {
return NextResponse.json(
{ error: "Invalid link ID format" },
{ status: 400 },
);
}
// Validate request body
const validation = AccessRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid request data" },
{ status: 400 },
);
}
const { email, code } = validation.data;
// Rate limiting
const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;
const { success } = await ratelimit(10, "1 m").limit(
`workflow-verify:${ipAddressValue}`,
);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 },
);
}
// Find workflow by entry link
const workflow = await prisma.workflow.findUnique({
where: { entryLinkId },
select: {
id: true,
isActive: true,
teamId: true,
entryLink: {
select: {
id: true,
isArchived: true,
deletedAt: true,
},
},
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow entry link not found" },
{ status: 404 },
);
}
if (!workflow.isActive) {
return NextResponse.json(
{ error: "This workflow is currently inactive" },
{ status: 403 },
);
}
if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {
return NextResponse.json(
{ error: "This link is no longer available" },
{ status: 404 },
);
}
// Verify OTP code
const verification = await prisma.verificationToken.findUnique({
where: {
token: code,
identifier: `workflow-otp:${entryLinkId}:${email}`,
},
});
if (!verification) {
return NextResponse.json(
{ error: "Invalid verification code", resetVerification: true },
{ status: 401 },
);
}
// Check OTP expiration
if (Date.now() > verification.expires.getTime()) {
await prisma.verificationToken.delete({
where: { token: code },
});
return NextResponse.json(
{ error: "Verification code expired", resetVerification: true },
{ status: 401 },
);
}
// Delete OTP after successful verification
await prisma.verificationToken.delete({
where: { token: code },
});
// Execute workflow engine
const engine = new WorkflowEngine();
const userAgent = req.headers.get("user-agent") ?? "unknown";
const referrer = req.headers.get("referer") ?? undefined;
const executionResult = await engine.execute(entryLinkId, {
visitorEmail: email,
visitorIp: ipAddressValue,
userAgent,
referrer,
});
if (!executionResult.success) {
return NextResponse.json(
{ error: executionResult.error || "Workflow execution failed" },
{ status: 400 },
);
}
// Find or create viewer
let viewer = await prisma.viewer.findUnique({
where: {
teamId_email: {
teamId: workflow.teamId,
email: email,
},
},
select: { id: true },
});
if (!viewer) {
viewer = await prisma.viewer.create({
data: {
email: email,
verified: true,
teamId: workflow.teamId,
},
select: { id: true },
});
}
// Create a view record for the workflow execution
const view = await prisma.view.create({
data: {
linkId: entryLinkId,
viewerEmail: email,
viewerId: viewer.id,
verified: true,
teamId: workflow.teamId,
viewType:
executionResult.targetLinkType === "DATAROOM_LINK"
? "DATAROOM_VIEW"
: "DOCUMENT_VIEW",
documentId: executionResult.targetDocumentId ?? undefined,
dataroomId: executionResult.targetDataroomId ?? undefined,
},
select: { id: true },
});
// Create link session for the target link
const { token: sessionToken, expiresAt } = await createLinkSession(
executionResult.targetLinkId!,
executionResult.targetLinkType!,
view.id,
email,
ipAddressValue,
userAgent,
true, // verified
viewer.id, // viewerId
executionResult.targetDocumentId,
executionResult.targetDataroomId,
);
// Parse target URL safely with fallback
let targetPath = `/view/${executionResult.targetLinkId}`;
let cookieFlagId = executionResult.targetLinkId;
if (executionResult.targetUrl) {
try {
const parsedUrl = new URL(executionResult.targetUrl);
targetPath = parsedUrl.pathname;
const pathSegment = parsedUrl.pathname.split("/").pop();
if (pathSegment) {
cookieFlagId = pathSegment;
}
} catch (error) {
console.error("Failed to parse target URL, using fallback values");
}
}
// Set link session cookie (httpOnly)
cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(expiresAt),
path: "/",
});
// Set client-readable flag cookie for auto-login detection
const flagCookieId = `pm_link_flag_${cookieFlagId}`;
cookies().set(flagCookieId, "true", {
httpOnly: false, // Client-readable
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(expiresAt),
path: targetPath,
});
// If routing to a dataroom, also create and set dataroom session cookie
if (
executionResult.targetLinkType === "DATAROOM_LINK" &&
executionResult.targetDataroomId &&
viewer
) {
const dataroomSession = await createDataroomSession(
executionResult.targetDataroomId,
executionResult.targetLinkId!,
view.id,
ipAddressValue,
true, // verified
viewer.id,
);
cookies().set(
`pm_drs_${executionResult.targetLinkId}`,
dataroomSession.token,
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(dataroomSession.expiresAt),
path: "/",
},
);
// Set client-readable flag cookie for dataroom
const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;
cookies().set(dataroomFlagId, "true", {
httpOnly: false, // Client-readable
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
expires: new Date(dataroomSession.expiresAt),
path: targetPath,
});
}
return NextResponse.json({
success: true,
targetUrl: executionResult.targetUrl,
targetLinkId: executionResult.targetLinkId,
targetLinkType: executionResult.targetLinkType,
});
} catch (error) {
console.error("Error verifying workflow access:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,142 @@
import { NextRequest, NextResponse } from "next/server";
import { ipAddress, waitUntil } from "@vercel/functions";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import { generateOTP } from "@/lib/utils/generate-otp";
import { validateEmail } from "@/lib/utils/validate-email";
import { VerifyEmailRequestSchema } from "@/ee/features/workflows/lib/types";
// POST /app/(ee)/api/workflow-entry/[entryLinkId]/verify - Send OTP
export async function POST(
req: NextRequest,
{ params }: { params: { entryLinkId: string } },
) {
try {
const { entryLinkId } = params;
const body = await req.json();
// Validate entryLinkId format
const linkIdValidation = z.string().cuid().safeParse(entryLinkId);
if (!linkIdValidation.success) {
return NextResponse.json(
{ error: "Invalid link ID format" },
{ status: 400 },
);
}
// Validate request body
const validation = VerifyEmailRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 },
);
}
const { email } = validation.data;
// Find workflow by entry link
const workflow = await prisma.workflow.findUnique({
where: { entryLinkId },
select: {
id: true,
isActive: true,
name: true,
teamId: true,
entryLink: {
select: {
id: true,
isArchived: true,
deletedAt: true,
},
},
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow entry link not found" },
{ status: 404 },
);
}
if (!workflow.isActive) {
return NextResponse.json(
{ error: "This workflow is currently inactive" },
{ status: 403 },
);
}
if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {
return NextResponse.json(
{ error: "This link is no longer available" },
{ status: 404 },
);
}
// Rate limit per email/entryLink combination (1 per 30 seconds) to prevent OTP flooding
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
`workflow-otp:${entryLinkId}:${email}`,
);
if (!emailLimitSuccess) {
return NextResponse.json(
{
error:
"Please wait before requesting another code. Try again in 30 seconds.",
},
{ status: 429 },
);
}
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
const ipAddressValue = ipAddress(req);
const { success } = await ratelimit(10, "1 m").limit(
`workflow-otp:${ipAddressValue}`,
);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 },
);
}
// Delete any existing OTP codes for this workflow entry + email
await prisma.verificationToken.deleteMany({
where: {
identifier: `workflow-otp:${entryLinkId}:${email}`,
},
});
// Generate and store OTP
const otpCode = generateOTP();
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes
await prisma.verificationToken.create({
data: {
token: otpCode,
identifier: `workflow-otp:${entryLinkId}:${email}`,
expires: expiresAt,
},
});
// Send OTP email
waitUntil(
sendOtpVerificationEmail(email, otpCode, false, workflow.teamId),
);
return NextResponse.json({
success: true,
message: "Verification code sent to your email",
});
} catch (error) {
console.error("Error sending workflow verification code:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
// GET /app/(ee)/api/workflows/[workflowId]/executions?teamId=xxx - List workflow executions
export async function GET(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Parse and validate pagination parameters
const rawPage = Number.parseInt(searchParams.get("page") || "1", 10);
const rawLimit = Number.parseInt(searchParams.get("limit") || "20", 10);
// Apply defaults for invalid values and enforce constraints
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
const limit = Number.isNaN(rawLimit) || rawLimit < 1
? 20
: Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100
const skip = (page - 1) * limit;
// Validate IDs format
const idsValidation = z.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
}).safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json(
{ error: "Invalid ID format" },
{ status: 400 },
);
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Check workflow exists and belongs to team
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Fetch executions with pagination
const [executions, totalCount] = await Promise.all([
prisma.workflowExecution.findMany({
where: { workflowId },
include: {
stepLogs: {
select: {
id: true,
workflowStepId: true,
conditionsMatched: true,
executedAt: true,
duration: true,
error: true,
},
orderBy: { executedAt: "asc" },
},
},
orderBy: { startedAt: "desc" },
skip,
take: limit,
}),
prisma.workflowExecution.count({
where: { workflowId },
}),
]);
return NextResponse.json({
executions,
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
},
});
} catch (error) {
console.error("Error fetching workflow executions:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,320 @@
import { NextRequest, NextResponse } from "next/server";
import {
UpdateWorkflowRequestSchema,
formatZodError,
} from "@/ee/features/workflows/lib/validation";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
// GET /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Get single workflow with details
export async function GET(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z
.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
})
.safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Fetch workflow
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId, // Ensure workflow belongs to the team
},
include: {
entryLink: {
select: {
id: true,
slug: true,
domainSlug: true,
},
},
steps: {
orderBy: { stepOrder: "asc" },
},
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Build entry URL
const entryUrl =
workflow.entryLink.domainSlug && workflow.entryLink.slug
? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`
: `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;
return NextResponse.json({
...workflow,
entryUrl,
});
} catch (error) {
console.error("Error fetching workflow:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// PATCH /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Update workflow
export async function PATCH(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z
.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
})
.safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
const body = await req.json();
// Validate request body
const validation = UpdateWorkflowRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: "Invalid request data",
details: formatZodError(validation.error),
},
{ status: 400 },
);
}
// Check workflow exists and belongs to team
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Update workflow
const updatedWorkflow = await prisma.workflow.update({
where: { id: workflowId },
data: validation.data,
include: {
entryLink: {
select: {
id: true,
slug: true,
domainSlug: true,
},
},
},
});
return NextResponse.json(updatedWorkflow);
} catch (error) {
console.error("Error updating workflow:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// DELETE /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Delete workflow
export async function DELETE(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z
.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
})
.safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Fetch workflow to check existence and get entryLinkId
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
entryLinkId: true,
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Delete workflow and entry link in transaction
// Note: Steps and executions will cascade delete via Prisma relations
await prisma.$transaction([
// Delete workflow first (cascade deletes steps and executions)
prisma.workflow.delete({
where: { id: workflowId },
}),
// Delete entry link
prisma.link.update({
where: { id: workflow.entryLinkId },
data: {
deletedAt: new Date(),
isArchived: true,
},
}),
]);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting workflow:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,359 @@
import { NextRequest, NextResponse } from "next/server";
import {
UpdateWorkflowStepRequestSchema,
formatZodError,
validateActions,
validateConditions,
} from "@/ee/features/workflows/lib/validation";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
// PATCH /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Update step
export async function PATCH(
req: NextRequest,
{ params }: { params: { workflowId: string; stepId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId, stepId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z
.object({
workflowId: z.string().cuid(),
stepId: z.string().cuid(),
teamId: z.string().cuid(),
})
.safeParse({ workflowId, stepId, teamId });
if (!idsValidation.success) {
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
const body = await req.json();
// Validate request body
const validation = UpdateWorkflowStepRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: "Invalid request data",
details: formatZodError(validation.error),
},
{ status: 400 },
);
}
// Validate conditions if provided
let validatedConditions: any | undefined;
if (validation.data.conditions) {
const conditionsValidation = validateConditions(
validation.data.conditions,
);
if (!conditionsValidation.valid) {
return NextResponse.json(
{ error: conditionsValidation.error },
{ status: 400 },
);
}
validatedConditions = conditionsValidation.data;
}
// Validate actions if provided
let validatedActions: any[] | undefined;
if (validation.data.actions) {
const actionsValidation = validateActions(validation.data.actions);
if (!actionsValidation.valid) {
return NextResponse.json(
{ error: actionsValidation.error },
{ status: 400 },
);
}
// Use actionsValidation.data so enrichment persists
validatedActions = actionsValidation.data;
// Validate target link exists and belongs to the team
const routeAction = validatedActions.find((a) => a.type === "route");
if (routeAction && routeAction.targetLinkId) {
// First get the workflow to get teamId
const workflow = await prisma.workflow.findUnique({
where: { id: workflowId },
select: { teamId: true },
});
if (workflow) {
const targetLink = await prisma.link.findUnique({
where: {
id: routeAction.targetLinkId,
teamId: workflow.teamId,
},
});
if (!targetLink) {
return NextResponse.json(
{ error: "Target link not found or not accessible" },
{ status: 400 },
);
}
// Update action with target details (mutates validatedActions)
if (
targetLink.linkType === "DOCUMENT_LINK" &&
targetLink.documentId
) {
routeAction.targetDocumentId = targetLink.documentId;
} else if (
targetLink.linkType === "DATAROOM_LINK" &&
targetLink.dataroomId
) {
routeAction.targetDataroomId = targetLink.dataroomId;
}
}
}
}
// Check step exists and workflow belongs to team
const step = await prisma.workflowStep.findUnique({
where: {
id: stepId,
workflowId: workflowId,
},
select: {
id: true,
workflow: {
select: {
id: true,
teamId: true,
},
},
},
});
if (!step || step.workflow.teamId !== teamId) {
return NextResponse.json({ error: "Step not found" }, { status: 404 });
}
// Extract emails and domains from conditions to sync with link allowList (if conditions updated)
let allowListItems: string[] | undefined;
if (validatedConditions) {
allowListItems = [];
if (validatedConditions.items) {
validatedConditions.items.forEach((condition: any) => {
const values = Array.isArray(condition.value)
? condition.value
: [condition.value];
if (condition.type === "domain") {
// Add @ prefix for domains in allowList
allowListItems!.push(...values.map((v: string) => `@${v}`));
} else if (condition.type === "email") {
allowListItems!.push(...values);
}
});
}
}
// Get target link ID (either from update or existing step)
let targetLinkId: string | undefined;
if (validatedActions) {
const routeAction = validatedActions.find((a) => a.type === "route");
targetLinkId = routeAction?.targetLinkId;
} else {
// Get from existing step
const existingStep = await prisma.workflowStep.findUnique({
where: { id: stepId },
select: { actions: true },
});
const existingActions = existingStep?.actions as any[];
const existingRouteAction = existingActions?.find(
(a) => a.type === "route",
);
targetLinkId = existingRouteAction?.targetLinkId;
}
// Build update data with validated conditions and actions (if provided)
const updateData: any = { ...validation.data };
if (validatedConditions) {
updateData.conditions = validatedConditions;
}
if (validatedActions) {
updateData.actions = validatedActions;
}
// Update step and optionally update link allowList in transaction
const updates: any[] = [
prisma.workflowStep.update({
where: { id: stepId },
data: updateData as any,
}),
];
// If we have allowList updates and a target link, sync the link
if (allowListItems !== undefined && targetLinkId) {
updates.push(
prisma.link.update({
where: { id: targetLinkId },
data: { allowList: allowListItems },
}),
);
}
const [updatedStep] = await prisma.$transaction(updates);
return NextResponse.json(updatedStep);
} catch (error) {
console.error("Error updating workflow step:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// DELETE /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Delete step
export async function DELETE(
req: NextRequest,
{ params }: { params: { workflowId: string; stepId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId, stepId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z
.object({
workflowId: z.string().cuid(),
stepId: z.string().cuid(),
teamId: z.string().cuid(),
})
.safeParse({ workflowId, stepId, teamId });
if (!idsValidation.success) {
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Check step exists and workflow belongs to team
const step = await prisma.workflowStep.findUnique({
where: {
id: stepId,
workflowId: workflowId,
},
select: {
id: true,
stepOrder: true,
workflow: {
select: {
id: true,
teamId: true,
},
},
},
});
if (!step || step.workflow.teamId !== teamId) {
return NextResponse.json({ error: "Step not found" }, { status: 404 });
}
// Get steps that need reordering (those after the deleted step)
const stepsToReorder = await prisma.workflowStep.findMany({
where: {
workflowId,
stepOrder: { gt: step.stepOrder },
},
select: {
id: true,
stepOrder: true,
},
});
// Delete step and reorder remaining steps in a transaction
await prisma.$transaction([
prisma.workflowStep.delete({
where: { id: stepId },
}),
...stepsToReorder.map((s) =>
prisma.workflowStep.update({
where: { id: s.id },
data: { stepOrder: s.stepOrder - 1 },
}),
),
]);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting workflow step:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,440 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import {
CreateWorkflowStepRequestSchema,
ReorderStepsRequestSchema,
formatZodError,
validateConditions,
validateActions,
} from "@/ee/features/workflows/lib/validation";
import { ReorderStepsRequest } from "@/ee/features/workflows/lib/types";
// GET /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - List all steps
export async function GET(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
}).safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json(
{ error: "Invalid ID format" },
{ status: 400 },
);
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Check workflow exists and belongs to team
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Fetch steps
const steps = await prisma.workflowStep.findMany({
where: { workflowId },
orderBy: { stepOrder: "asc" },
});
// Enrich steps with target link details
const enrichedSteps = await Promise.all(
steps.map(async (step) => {
const actions = step.actions as any[];
const routeAction = actions.find((a) => a.type === "route");
if (routeAction && routeAction.targetLinkId) {
const targetLink = await prisma.link.findUnique({
where: { id: routeAction.targetLinkId },
select: {
id: true,
name: true,
slug: true,
domainSlug: true,
linkType: true,
},
});
return {
...step,
targetLink,
};
}
return step;
}),
);
return NextResponse.json(enrichedSteps);
} catch (error) {
console.error("Error fetching workflow steps:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// POST /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Create a new step
export async function POST(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
}).safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json(
{ error: "Invalid ID format" },
{ status: 400 },
);
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
const body = await req.json();
// Validate request body
const validation = CreateWorkflowStepRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: "Invalid request data",
details: formatZodError(validation.error),
},
{ status: 400 },
);
}
const { name, conditions, actions } = validation.data;
// Validate conditions and actions
const conditionsValidation = validateConditions(conditions);
if (!conditionsValidation.valid) {
return NextResponse.json(
{ error: conditionsValidation.error },
{ status: 400 },
);
}
const actionsValidation = validateActions(actions);
if (!actionsValidation.valid) {
return NextResponse.json(
{ error: actionsValidation.error },
{ status: 400 },
);
}
// Check workflow exists and belongs to team
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
teamId: true,
steps: {
select: { stepOrder: true },
orderBy: { stepOrder: "desc" },
take: 1,
},
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Validate target link exists and belongs to the team
// Use actionsValidation.data so enrichment persists
const routeAction = actionsValidation.data.find((a) => a.type === "route");
if (routeAction && routeAction.targetLinkId) {
const targetLink = await prisma.link.findUnique({
where: {
id: routeAction.targetLinkId,
teamId: workflow.teamId,
},
});
if (!targetLink) {
return NextResponse.json(
{ error: "Target link not found or not accessible" },
{ status: 400 },
);
}
// Update action with target details (mutates actionsValidation.data)
if (targetLink.linkType === "DOCUMENT_LINK" && targetLink.documentId) {
routeAction.targetDocumentId = targetLink.documentId;
} else if (
targetLink.linkType === "DATAROOM_LINK" &&
targetLink.dataroomId
) {
routeAction.targetDataroomId = targetLink.dataroomId;
}
}
// Calculate next step order
const maxStepOrder = workflow.steps[0]?.stepOrder ?? -1;
const nextStepOrder = maxStepOrder + 1;
// Extract emails and domains from conditions to sync with link allowList
const allowListItems: string[] = [];
if (conditionsValidation.data.items) {
conditionsValidation.data.items.forEach((condition: any) => {
const values = Array.isArray(condition.value)
? condition.value
: [condition.value];
if (condition.type === "domain") {
// Add @ prefix for domains in allowList
allowListItems.push(...values.map((v: string) => `@${v}`));
} else if (condition.type === "email") {
allowListItems.push(...values);
}
});
}
// Create step and conditionally update target link's allowList in transaction
const transactionSteps: any[] = [
prisma.workflowStep.create({
data: {
workflowId,
name,
stepOrder: nextStepOrder,
stepType: "ROUTER",
conditions: conditionsValidation.data as any,
actions: actionsValidation.data as any,
},
}),
];
// Only update link allowList if we have a route action with a target link
if (routeAction && routeAction.targetLinkId) {
transactionSteps.push(
prisma.link.update({
where: { id: routeAction.targetLinkId },
data: {
allowList: allowListItems,
},
}),
);
}
const [newStep] = await prisma.$transaction(transactionSteps);
return NextResponse.json(newStep, { status: 201 });
} catch (error) {
console.error("Error creating workflow step:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// PUT /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Reorder steps
export async function PUT(
req: NextRequest,
{ params }: { params: { workflowId: string } },
) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { workflowId } = params;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate IDs format
const idsValidation = z.object({
workflowId: z.string().cuid(),
teamId: z.string().cuid(),
}).safeParse({ workflowId, teamId });
if (!idsValidation.success) {
return NextResponse.json(
{ error: "Invalid ID format" },
{ status: 400 },
);
}
const userId = (session.user as CustomUser).id;
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
const body = await req.json();
// Validate request body
const validation = ReorderStepsRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: "Invalid request data",
details: formatZodError(validation.error),
},
{ status: 400 },
);
}
// Check workflow exists and belongs to team
const workflow = await prisma.workflow.findUnique({
where: {
id: workflowId,
teamId: teamId,
},
select: {
id: true,
},
});
if (!workflow) {
return NextResponse.json(
{ error: "Workflow not found" },
{ status: 404 },
);
}
// Update step orders in a transaction
await prisma.$transaction(
validation.data.steps.map((step) =>
prisma.workflowStep.update({
where: {
id: step.stepId,
workflowId, // Ensure step belongs to this workflow
},
data: {
stepOrder: step.stepOrder,
},
}),
),
);
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error reordering workflow steps:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+287
View File
@@ -0,0 +1,287 @@
import { NextRequest, NextResponse } from "next/server";
import {
CreateWorkflowRequestSchema,
formatZodError,
} from "@/ee/features/workflows/lib/validation";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
// GET /app/(ee)/api/workflows?teamId=xxx - List all workflows for a team
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate teamId format
const teamIdValidation = z.string().cuid().safeParse(teamId);
if (!teamIdValidation.success) {
return NextResponse.json(
{ error: "Invalid teamId format" },
{ status: 400 },
);
}
// Check user is part of the team
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: (session.user as CustomUser).id,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
// Fetch workflows with entry link and step count
const workflows = await prisma.workflow.findMany({
where: { teamId },
include: {
entryLink: {
select: {
id: true,
slug: true,
domainSlug: true,
},
},
_count: {
select: {
steps: true,
executions: true,
},
},
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(workflows);
} catch (error) {
console.error("Error fetching workflows:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
// POST /app/(ee)/api/workflows?teamId=xxx - Create a new workflow
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
const searchParams = req.nextUrl.searchParams;
const teamId = searchParams.get("teamId");
if (!teamId) {
return NextResponse.json(
{ error: "teamId parameter is required" },
{ status: 400 },
);
}
// Validate teamId format
const teamIdValidation = z.string().cuid().safeParse(teamId);
if (!teamIdValidation.success) {
return NextResponse.json(
{ error: "Invalid teamId format" },
{ status: 400 },
);
}
// Check user is part of the team using userTeam table
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!teamAccess) {
return NextResponse.json(
{ error: "Unauthorized to access this team" },
{ status: 403 },
);
}
const body = await req.json();
// Validate request body
const validation = CreateWorkflowRequestSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
error: "Invalid request data",
details: formatZodError(validation.error),
},
{ status: 400 },
);
}
const { name, description, domain, slug } = validation.data;
// Get team details for plan check
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
id: true,
plan: true,
},
});
if (!team) {
return NextResponse.json({ error: "Team not found" }, { status: 404 });
}
// Check if workflows feature flag is enabled
const featureFlags = await getFeatureFlags({ teamId });
if (!featureFlags.workflows) {
return NextResponse.json(
{ error: "This feature is not available for your team" },
{ status: 403 },
);
}
// Check plan - require Business or DataRooms plan
if (team.plan === "free" || team.plan === "pro") {
return NextResponse.json(
{
error: "Workflows require a Business or Data Rooms plan",
requiresUpgrade: true,
},
{ status: 403 },
);
}
// Validate domain and slug
let domainId: string | null = null;
let domainSlug: string | null = null;
if (domain && slug) {
// Check if domain exists and belongs to team
const domainRecord = await prisma.domain.findUnique({
where: {
slug: domain,
teamId: teamId,
},
select: { id: true, slug: true },
});
if (!domainRecord) {
return NextResponse.json(
{ error: "Domain not found or not associated with this team" },
{ status: 400 },
);
}
domainId = domainRecord.id;
domainSlug = domainRecord.slug;
// Check if slug is already in use on this domain
const existingLink = await prisma.link.findUnique({
where: {
domainSlug_slug: {
slug: slug,
domainSlug: domain,
},
},
});
if (existingLink) {
return NextResponse.json(
{ error: "This slug is already in use on the selected domain" },
{ status: 400 },
);
}
}
// Create workflow with entry link in a transaction
const workflow = await prisma.$transaction(async (tx) => {
// Create entry link
const entryLink = await tx.link.create({
data: {
linkType: "WORKFLOW_LINK",
teamId,
name: `${name} - Entry Link`,
slug: slug || null,
domainId: domainId,
domainSlug: domainSlug,
emailProtected: true, // Workflows always require email
emailAuthenticated: true, // Workflows always require OTP
allowDownload: false,
enableNotification: false,
},
});
// Create workflow
const newWorkflow = await tx.workflow.create({
data: {
name,
description,
teamId,
entryLinkId: entryLink.id,
isActive: true,
},
include: {
entryLink: {
select: {
id: true,
slug: true,
domainSlug: true,
},
},
},
});
return newWorkflow;
});
// Build entry URL
const entryUrl =
workflow.entryLink.domainSlug && workflow.entryLink.slug
? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`
: `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;
return NextResponse.json(
{
...workflow,
entryUrl,
},
{ status: 201 },
);
} catch (error) {
console.error("Error creating workflow:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchAndDeleteLoginCodeData } from "@/lib/emails/send-verification-request";
import { ratelimit } from "@/lib/redis";
// Rate limiters
const emailRateLimit = ratelimit(5, "1 m"); // 5 attempts per minute per email
const ipRateLimit = ratelimit(10, "1 m"); // 10 attempts per minute per IP
function getClientIp(request: NextRequest): string {
const forwarded = request.headers.get("x-forwarded-for");
const realIp = request.headers.get("x-real-ip");
return forwarded?.split(",")[0]?.trim() || realIp || "unknown";
}
// POST: Verify via email + code
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, code } = body;
// Type checks first to prevent calling .trim() on non-strings
if (typeof email !== "string" || typeof code !== "string") {
return NextResponse.json(
{ error: "Email and code are required." },
{ status: 400 },
);
}
// Normalize after type check
const normalizedEmail = email.trim().toLowerCase();
const normalizedCode = code.trim().toUpperCase();
// Validate non-empty email and exact code length
if (!normalizedEmail || normalizedCode.length !== 10) {
return NextResponse.json(
{ error: "Invalid email or code format." },
{ status: 400 },
);
}
const ip = getClientIp(request);
// Check both rate limits
const [emailLimit, ipLimit] = await Promise.all([
emailRateLimit.limit(`verify_code:${normalizedEmail}`),
ipRateLimit.limit(`verify_code:ip:${ip}`),
]);
if (!emailLimit.success) {
return NextResponse.json(
{
error: "Too many attempts. Please wait before trying again.",
retryAfter: Math.ceil((emailLimit.reset - Date.now()) / 1000),
remaining: 0,
},
{ status: 429 },
);
}
if (!ipLimit.success) {
return NextResponse.json(
{
error: "Too many attempts. Please wait before trying again.",
retryAfter: Math.ceil((ipLimit.reset - Date.now()) / 1000),
remaining: 0,
},
{ status: 429 },
);
}
// Atomically fetch and delete to prevent TOCTOU race condition
const loginCodeData = await fetchAndDeleteLoginCodeData(
normalizedEmail,
normalizedCode,
);
if (!loginCodeData) {
return NextResponse.json(
{
error: "Invalid code. Please check your email and try again.",
remaining: emailLimit.remaining,
},
{ status: 401 },
);
}
const { callbackUrl } = loginCodeData;
return NextResponse.json({ callbackUrl });
} catch (error) {
console.error("Error verifying code:", error);
return NextResponse.json(
{ error: "Verification failed. Please try again." },
{ status: 500 },
);
}
}
+1 -2
View File
@@ -37,9 +37,8 @@ export async function POST(req: Request) {
const domains = await prisma.domain.findMany({
where: {
slug: {
// exclude domains that belong to us
not: {
contains: "papermark.io",
in: ["papermark.io", "papermark.com"],
},
},
},
-158
View File
@@ -1,158 +0,0 @@
import { NextResponse } from "next/server";
import { limiter, receiver } from "@/lib/cron";
import { sendTrialEndFinalReminderEmail } from "@/lib/emails/send-trial-end-final-reminder";
import { sendTrialEndReminderEmail } from "@/lib/emails/send-trial-end-reminder";
import prisma from "@/lib/prisma";
import { calculateDaysLeft, log } from "@/lib/utils";
/**
* Cron to check if trial has expired.
* If a user is not on pro plan and has 5 days left on trial, we send a reminder email to upgrade plan.
* If a user is not on pro plan and has 1 day left on trial, we send a final reminder email to upgrade plan.
**/
// Runs once per day at 12pm (0 12 * * *)
export const maxDuration = 300; // 5 minutes in seconds
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const teams = await prisma.team.findMany({
where: {
plan: {
// exclude users who are on pro or free plan
in: ["trial"],
},
},
select: {
id: true,
createdAt: true,
users: {
where: { role: "ADMIN" },
select: {
user: {
select: { email: true, name: true },
},
},
},
sentEmails: {
where: {
type: {
in: [
"FIRST_TRIAL_END_REMINDER_EMAIL",
"FINAL_TRIAL_END_REMINDER_EMAIL",
],
},
},
select: { type: true },
},
},
});
const results = await Promise.allSettled(
teams.map(async (team) => {
const { id, users, createdAt } = team as {
id: string;
createdAt: Date;
users: {
user: { email: string; name: string | null };
}[];
};
const sentEmails = team.sentEmails.map((email) => email.type);
const userEmail = users[0].user.email;
const userName = users[0].user.name;
const teamCreatedAt = createdAt;
let teamDaysLeft = calculateDaysLeft(new Date(teamCreatedAt));
// send first reminder email if team has 5 days left on trial
if (teamDaysLeft == 5) {
const sentFirstTrialEndReminderEmail = sentEmails.includes(
"FIRST_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFirstTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FIRST_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// send final reminder email if team has 1 day left on trial
if (teamDaysLeft <= 1) {
const sentFinalTrialEndReminderEmail = sentEmails.includes(
"FINAL_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFinalTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Final Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndFinalReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FINAL_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// downgrade the user to free if team has 0 day left on trial
if (teamDaysLeft <= 0) {
return await Promise.allSettled([
log({
message: `Downgrade to free for user: *${id}* is expiring in ${teamDaysLeft} days, downgraded.`,
type: "cron",
}),
prisma.user.update({
where: { email: userEmail },
data: { plan: "free" },
}),
prisma.team.update({
where: { id },
data: { plan: "free" },
}),
]);
}
}),
);
return NextResponse.json(results);
} catch (error) {
await log({
message: `Trial end reminder cron failed. Error: " + ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { sendWelcomeEmail } from "@/lib/emails/send-welcome";
import prisma from "@/lib/prisma";
import { subscribe } from "@/lib/resend";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });
const { userId } = JSON.parse(rawBody);
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
name: true,
email: true,
},
});
if (!user) {
return new Response("User not found. Skipping...", { status: 200 });
}
// this shouldn't happen but just in case
if (!user.email) {
return new Response("User email not found. Skipping...", { status: 200 });
}
await Promise.allSettled([
// send welcome email
sendWelcomeEmail({
user: {
email: user.email,
name: user.name,
},
}),
// subscribe user to the mailing list
subscribe(user.email),
]);
return new Response("Welcome email sent and user subscribed.", {
status: 200,
});
} catch (error) {
console.error(error);
return new Response(
"Error sending welcome email and subscribing user to mailing list.",
{ status: 500 },
);
}
}
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { z } from "zod";
import { getSlackInstallationUrl } from "@/lib/integrations/slack/install";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
const oAuthAuthorizeSchema = z.object({
teamId: z.string().cuid(),
});
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { teamId } = oAuthAuthorizeSchema.parse(getSearchParams(req.url));
const userId = (session.user as CustomUser).id;
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const oauthUrl = await getSlackInstallationUrl(teamId);
return NextResponse.json({
oauthUrl,
});
} catch (error) {
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,115 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { Team } from "@prisma/client";
import { getServerSession } from "next-auth";
import z from "zod";
import { installIntegration } from "@/lib/integrations/install";
import { getSlackEnv } from "@/lib/integrations/slack/env";
import { SlackCredential } from "@/lib/integrations/slack/types";
import { encryptSlackToken } from "@/lib/integrations/slack/utils";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
export const dynamic = "force-dynamic";
const oAuthCallbackSchema = z.object({
code: z.string(),
state: z.string(),
});
export const GET = async (req: Request) => {
const env = getSlackEnv();
let team: Pick<Team, "id" | "plan"> | null = null;
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));
// Find workspace that initiated the Stripe app install
const stateKey = `slack:install:state:${state}`;
const teamId = await redis.get<string>(stateKey);
if (!teamId) {
return NextResponse.json({ error: "Invalid state" }, { status: 400 });
}
await redis.del(stateKey);
team = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
users: {
some: {
userId,
},
},
},
select: {
id: true,
plan: true,
},
});
if (!team) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const body = new URLSearchParams({
code,
client_id: env.SLACK_CLIENT_ID,
client_secret: env.SLACK_CLIENT_SECRET,
redirect_uri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,
});
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), 10000);
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
signal: ac.signal,
}).finally(() => clearTimeout(to));
const data = await response.json();
if (!data?.ok) {
return NextResponse.json(
{ error: `Slack OAuth error: ${data?.error || "unknown"}` },
{ status: 400 },
);
}
const credentials: SlackCredential = {
appId: data.app_id,
botUserId: data.bot_user_id,
scope: data.scope,
accessToken: encryptSlackToken(data.access_token),
tokenType: data.token_type,
authUser: data.authed_user,
team: data.team,
};
await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
redirect(`/settings/slack?success=true`);
};
+6
View File
@@ -0,0 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
// Legacy magic link route - redirect to new code-based flow
export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL("/auth/email", request.url));
}
+98 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { ItemType, LinkAudienceType } from "@prisma/client";
@@ -16,6 +17,10 @@ import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import {
notifyDataroomAccess,
notifyDocumentView,
} from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
@@ -98,6 +103,8 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -106,6 +113,7 @@ export async function POST(request: NextRequest) {
password: true,
domainSlug: true,
isArchived: true,
deletedAt: true,
slug: true,
domainId: true,
allowList: true,
@@ -124,6 +132,8 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -133,6 +143,12 @@ export async function POST(request: NextRequest) {
},
},
enableUpload: true,
dataroom: {
select: {
agentsEnabled: true,
name: true,
},
},
},
});
@@ -147,6 +163,13 @@ export async function POST(request: NextRequest) {
);
}
if (link.deletedAt) {
return NextResponse.json(
{ message: "Link has been deleted." },
{ status: 404 },
);
}
let isEmailVerified: boolean = false;
let hashedVerificationToken: string | null = null;
// Check if the user is part of the team and therefore skip verification steps
@@ -286,6 +309,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -298,6 +323,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -314,6 +341,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -356,6 +385,7 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -370,6 +400,21 @@ export async function POST(request: NextRequest) {
if (link.emailAuthenticated && !code && !token && !dataroomVerified) {
const ipAddressValue = ipAddress(request);
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
`send-otp:${linkId}:${email}`,
);
if (!emailLimitSuccess) {
return NextResponse.json(
{
message:
"Please wait before requesting another code. Try again in 30 seconds.",
},
{ status: 429 },
);
}
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
const { success } = await ratelimit(10, "1 m").limit(
`send-otp:${ipAddressValue}`,
);
@@ -617,6 +662,11 @@ export async function POST(request: NextRequest) {
}),
};
const isPaused =
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
? true
: false;
// ** DATAROOM_VIEW **
if (viewType === "DATAROOM_VIEW") {
console.log("viewType is DATAROOM_VIEW");
@@ -645,8 +695,28 @@ export async function POST(request: NextRequest) {
dataroomId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDataroomAccess({
teamId: link.teamId!,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
teamIsPaused: isPaused,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
const dataroomViewId =
@@ -663,6 +733,8 @@ export async function POST(request: NextRequest) {
viewerId: viewer?.id,
conversationsEnabled: link.enableConversation,
enableVisitorUpload: link.enableUpload,
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
dataroomName: link.dataroom?.name,
...(isTeamMember && { isTeamMember: true }),
};
@@ -742,6 +814,7 @@ export async function POST(request: NextRequest) {
dataroomId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
}
@@ -758,6 +831,26 @@ export async function POST(request: NextRequest) {
select: { id: true },
});
console.timeEnd("create-view");
// Only send Slack notifications for non-preview views
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDocumentView({
teamId: link.teamId!,
documentId,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
teamIsPaused: isPaused,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
// if document version has pages, then return pages
@@ -821,6 +914,7 @@ export async function POST(request: NextRequest) {
type: documentVersion.storageType,
});
}
// For link documents, the file is already a URL, no processing needed
if (documentVersion.type === "sheet") {
const document = await prisma.document.findUnique({
where: { id: documentId },
@@ -917,7 +1011,8 @@ export async function POST(request: NextRequest) {
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "zip" ||
documentVersion.type === "video")) ||
documentVersion.type === "video" ||
documentVersion.type === "link")) ||
(documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
@@ -955,6 +1050,8 @@ export async function POST(request: NextRequest) {
canDownload: canDownload,
viewerId: viewer?.id,
conversationsEnabled: link.enableConversation,
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
dataroomName: link.dataroom?.name,
...(isTeamMember && { isTeamMember: true }),
};
+70 -2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
// Import authOptions directly from the source
import { authOptions } from "@/pages/api/auth/[...nextauth]";
@@ -13,13 +14,14 @@ import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verificati
import { getFeatureFlags } from "@/lib/featureFlags";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import { notifyDocumentView } from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
import { recordLinkView } from "@/lib/tracking/record-link-view";
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils";
import { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
import { isEmailMatched } from "@/lib/utils/email-domain";
import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
@@ -84,12 +86,16 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
emailProtected: true,
enableNotification: true,
emailAuthenticated: true,
password: true,
domainSlug: true,
isArchived: true,
deletedAt: true,
slug: true,
allowList: true,
denyList: true,
@@ -102,6 +108,8 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -110,6 +118,11 @@ export async function POST(request: NextRequest) {
label: true,
},
},
document: {
select: {
agentsEnabled: true,
},
},
},
});
@@ -126,6 +139,13 @@ export async function POST(request: NextRequest) {
);
}
if (link.deletedAt) {
return NextResponse.json(
{ message: "Link has been deleted." },
{ status: 404 },
);
}
let isEmailVerified: boolean = false;
let hashedVerificationToken: string | null = null;
// Check if the user is part of the team and therefore skip verification steps
@@ -221,6 +241,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -233,6 +255,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -249,6 +273,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -262,6 +288,21 @@ export async function POST(request: NextRequest) {
if (link.emailAuthenticated && !code && !token) {
const ipAddressValue = ipAddress(request);
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
`send-otp:${linkId}:${email}`,
);
if (!emailLimitSuccess) {
return NextResponse.json(
{
message:
"Please wait before requesting another code. Try again in 30 seconds.",
},
{ status: 429 },
);
}
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
const { success } = await ratelimit(10, "1 m").limit(
`send-otp:${ipAddressValue}`,
);
@@ -612,6 +653,11 @@ export async function POST(request: NextRequest) {
console.timeEnd("get-file");
}
const isPaused =
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
? true
: false;
if (newView) {
// Record view in the background to avoid blocking the response
waitUntil(
@@ -624,20 +670,41 @@ export async function POST(request: NextRequest) {
documentId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
if (!isPreview) {
waitUntil(
notifyDocumentView({
teamId: link.teamId!,
documentId,
linkId,
viewerEmail: email ?? undefined,
viewerId: viewer?.id ?? undefined,
teamIsPaused: isPaused,
}).catch((error) => {
console.error("Error sending Slack notification:", error);
}),
);
}
}
// Determine if AI agents should be enabled (requires both team and document level)
const agentsEnabled =
link.team?.agentsEnabled && link.document?.agentsEnabled;
const returnObject = {
message: "View recorded",
viewId: !isPreview && newView ? newView.id : undefined,
viewerId: viewer?.id ?? undefined,
isPreview: isPreview ? true : undefined,
file:
(documentVersion &&
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "zip" ||
documentVersion.type === "video")) ||
documentVersion.type === "video" ||
documentVersion.type === "link")) ||
(documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
@@ -668,6 +735,7 @@ export async function POST(request: NextRequest) {
: undefined,
verificationToken: hashedVerificationToken ?? undefined,
...(isTeamMember && { isTeamMember: true }),
...(agentsEnabled && { agentsEnabled: true }),
};
return NextResponse.json(returnObject);
-8
View File
@@ -1,8 +1,6 @@
import { Metadata } from "next";
import { Inter } from "next/font/google";
import PlausibleProvider from "next-plausible";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
@@ -49,12 +47,6 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<head>
<PlausibleProvider
domain="papermark.io"
enabled={process.env.NEXT_PUBLIC_VERCEL_ENV === "production"}
/>
</head>
<body className={inter.className}>{children}</body>
</html>
);
+5 -5
View File
@@ -1,13 +1,12 @@
import { useOptimisticUpdate } from "../hooks/use-optimistic-update";
import { Switch } from "../ui/switch";
import { useOptimisticUpdate } from "@/components/hooks/use-optimistic-update";
import { Switch } from "@/components/ui/switch";
// TODO: MAIL SUBSCRIPTION FOR LATER USE
export const UpdateMailSubscribe = () => {
const { data, isLoading, update } = useOptimisticUpdate<{
subscribed: boolean;
}>("/api/user/subscribe", {
loading: "Updating email preferences...",
success: `Your ${process.env.NEXT_PUBLIC_BASE_URL} email preferences has been updated!`,
success: "Your email preferences have been updated!",
error: "Failed to update email preferences. Please try again.",
});
@@ -25,7 +24,8 @@ export const UpdateMailSubscribe = () => {
return (
<div className="flex items-center gap-x-2">
<Switch
checked={data?.subscribed}
checked={data?.subscribed ?? true}
disabled={isLoading}
onCheckedChange={(checked: boolean) => {
update(() => subscribe(checked), { subscribed: checked });
}}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
+448
View File
@@ -0,0 +1,448 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[80%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
"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",
"group-[.is-assistant]:text-foreground",
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom" className="px-1.5 py-1 text-sm">
<span>{tooltip}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null,
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch",
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className,
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:m-0 [&_li>p]:inline",
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className,
)}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
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"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className,
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className,
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
"use client";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
hoverOnly?: boolean;
};
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
hoverOnly = false,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
);
const animationProps = hoverOnly
? {
initial: { backgroundPosition: "100% center" },
whileHover: { backgroundPosition: "0% center" },
}
: {
initial: { backgroundPosition: "100% center" },
animate: { backgroundPosition: "0% center" },
};
const transitionProps = hoverOnly
? {
duration,
ease: "linear" as const,
}
: {
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear" as const,
};
return (
<MotionComponent
{...animationProps}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))",
} as CSSProperties
}
transition={transitionProps}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+24 -19
View File
@@ -34,13 +34,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { usePlan } from "@/lib/swr/use-billing";
import { fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Document {
id: string;
@@ -151,11 +152,20 @@ const columns: ColumnDef<Document>[] = [
</Button>
);
},
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
</div>
),
cell: ({ row }) =>
row.original.lastViewed ? (
<TimestampTooltip
timestamp={row.original.lastViewed}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.lastViewed)}
</div>
</TimestampTooltip>
) : (
<div className="text-sm text-muted-foreground">-</div>
),
},
];
@@ -221,20 +231,15 @@ export default function DocumentsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -251,7 +256,7 @@ export default function DocumentsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+29 -24
View File
@@ -27,6 +27,11 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Table,
@@ -36,14 +41,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { usePlan } from "@/lib/swr/use-billing";
import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface Link {
id: string;
@@ -213,11 +214,20 @@ const columns: ColumnDef<Link>[] = [
</Button>
);
},
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
</div>
),
cell: ({ row }) =>
row.original.lastViewed ? (
<TimestampTooltip
timestamp={row.original.lastViewed}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.lastViewed)}
</div>
</TimestampTooltip>
) : (
<div className="text-sm text-muted-foreground">-</div>
),
},
];
@@ -285,20 +295,15 @@ export default function LinksTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -315,7 +320,7 @@ export default function LinksTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+1 -1
View File
@@ -16,7 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
const TIME_RANGES = [
{ value: "24h", label: "Last 24 hours", shortcut: "D" },
+44 -34
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
@@ -16,6 +16,7 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
BadgeInfoIcon,
ChevronDownIcon,
@@ -24,8 +25,6 @@ import {
Download,
DownloadCloudIcon,
FileBadgeIcon,
FileDigitIcon,
MoreHorizontalIcon,
ServerIcon,
ThumbsDownIcon,
ThumbsUpIcon,
@@ -38,11 +37,6 @@ import { cn, durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
@@ -52,16 +46,12 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import VisitorChart from "@/components/visitors/visitor-chart";
import VisitorClicks from "@/components/visitors/visitor-clicks";
import VisitorCustomFields from "@/components/visitors/visitor-custom-fields";
import VisitorUserAgent from "@/components/visitors/visitor-useragent";
import VisitorVideoChart from "@/components/visitors/visitor-video-chart";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface View {
id: string;
@@ -86,7 +76,7 @@ interface View {
const columns: ColumnDef<View>[] = [
{
accessorKey: "viewerEmail",
header: "Recent Visits",
header: "Recent Views",
cell: ({ row }) => (
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={row.original.viewerEmail} />
@@ -259,7 +249,7 @@ const columns: ColumnDef<View>[] = [
"px-0",
)}
>
Last Visited
Last Viewed
{column.getIsSorted() === "asc" ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
@@ -271,9 +261,15 @@ const columns: ColumnDef<View>[] = [
);
},
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{timeAgo(row.original.viewedAt)}
</div>
<TimestampTooltip
timestamp={row.original.viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.viewedAt)}
</div>
</TimestampTooltip>
),
},
];
@@ -287,13 +283,13 @@ export default function ViewsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree } = usePlan();
const { isTrial, isFree, isPaused } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "viewedAt", desc: true },
]);
const { data: views } = useSWR<View[]>(
const { data } = useSWR<{ views: View[]; hiddenFromPause: number }>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=views&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -304,6 +300,9 @@ export default function ViewsTable({
},
);
const views = data?.views;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: views || [],
columns,
@@ -341,20 +340,15 @@ export default function ViewsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -368,6 +362,22 @@ export default function ViewsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<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">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<Link
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</Link>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
@@ -438,7 +448,7 @@ export default function ViewsTable({
colSpan={columns.length}
className="h-24 text-center"
>
<p>No visits in the last {interval}</p>
<p>No views in the last {interval}</p>
</TableCell>
</TableRow>
)}
+43 -22
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -15,6 +16,7 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
ChevronDownIcon,
ChevronUpIcon,
@@ -25,8 +27,11 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
TableBody,
@@ -39,11 +44,7 @@ import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface Visitor {
email: string;
@@ -53,6 +54,7 @@ interface Visitor {
uniqueDocuments: number;
verified: boolean;
totalDuration: number;
viewerName?: string | null;
}
const columns: ColumnDef<Visitor>[] = [
@@ -65,13 +67,18 @@ const columns: ColumnDef<Visitor>[] = [
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{row.original.email}{" "}
{row.original.viewerName || row.original.email}{" "}
{row.original.verified && (
<BadgeTooltip content="Verified visitor">
<BadgeCheckIcon className="h-4 w-4 text-emerald-500 hover:text-emerald-600" />
</BadgeTooltip>
)}
</p>
{row.original.viewerName && row.original.email && (
<p className="text-xs text-muted-foreground/60">
{row.original.email}
</p>
)}
<p className="text-sm text-muted-foreground">
{row.original.uniqueDocuments} document
{row.original.uniqueDocuments !== 1 ? "s" : ""} viewed
@@ -216,13 +223,13 @@ export default function VisitorsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree } = usePlan();
const { isTrial, isFree, isPaused } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "lastActive", desc: true },
]);
const { data: visitors } = useSWR<Visitor[]>(
const { data } = useSWR<{ visitors: Visitor[]; hiddenFromPause: number }>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=visitors&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -233,6 +240,9 @@ export default function VisitorsTable({
},
);
const visitors = data?.visitors;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: visitors || [],
columns,
@@ -269,20 +279,15 @@ export default function VisitorsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -296,10 +301,26 @@ export default function VisitorsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<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">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<Link
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</Link>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+15 -6
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -37,17 +38,18 @@ export function AddSeatModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const analytics = useAnalytics();
const { plan: userPlan, planName, isAnnualPlan } = usePlan();
const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = usePlan();
const { limits } = useLimits();
const [quantity, setQuantity] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
// Get the minimum quantity for the current plan
const priceId = getPriceIdFromPlan(
planName,
isAnnualPlan ? "yearly" : "monthly",
);
const priceId = getPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period: isAnnualPlan ? "yearly" : "monthly",
});
const minQuantity = getQuantityFromPriceId(priceId);
// Set initial quantity to 1 (adding one seat)
@@ -179,10 +181,17 @@ export function AddSeatModal({
)}
</div>
<DialogFooter>
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:justify-center">
<Button onClick={handleSubmit} className="w-full" disabled={loading}>
{loading ? "Redirecting..." : "Proceed to checkout"}
</Button>
<Link
href="/settings/upgrade"
className="block w-full text-center text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
onClick={() => setOpen(false)}
>
or upgrade to higher plan
</Link>
</DialogFooter>
</DialogContent>
</Dialog>
+7 -6
View File
@@ -5,20 +5,18 @@ import { Dispatch, SetStateAction, useState } from "react";
import { useTeam } from "@/context/team-context";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import { toast } from "sonner";
import { usePlan } from "@/lib/swr/use-billing";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
import { usePlan } from "@/lib/swr/use-billing";
export default function ProAnnualBanner({
setShowProAnnualBanner,
}: {
setShowProAnnualBanner: Dispatch<SetStateAction<boolean | null>>;
}) {
const plausible = usePlausible();
const router = useRouter();
const teamInfo = useTeam();
const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();
@@ -27,7 +25,6 @@ export default function ProAnnualBanner({
const handleHideBanner = () => {
setShowProAnnualBanner(false);
plausible("clickedHideProAnnualBanner");
Cookies.set("hideProAnnualBanner", "pro-annual-banner", {
expires: 7,
});
@@ -63,7 +60,11 @@ export default function ProAnnualBanner({
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId: getPriceIdFromPlan("Pro", "yearly"),
priceId: getPriceIdFromPlan({
planSlug: "pro",
isOld: isOldAccount,
period: "yearly",
}),
upgradePlan: true,
proAnnualBanner: true,
}),
+2 -12
View File
@@ -2,7 +2,6 @@ import { Dispatch, SetStateAction } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
@@ -14,11 +13,8 @@ export default function ProBanner({
}: {
setShowProBanner: Dispatch<SetStateAction<boolean | null>>;
}) {
const plausible = usePlausible();
const handleHideBanner = () => {
setShowProBanner(false);
plausible("clickedHideBanner");
Cookies.set("hideProBanner", "pro-banner", {
expires: 7,
});
@@ -42,16 +38,10 @@ export default function ProBanner({
</p>
<div className="flex">
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
clickedPlan={PlanEnum.Business}
trigger={"pro_banner"}
>
<Button
type="button"
className="grow"
onClick={() => {
plausible("clickedProBanner");
}}
>
<Button type="button" className="grow">
Upgrade
</Button>
</UpgradePlanModal>
+317 -44
View File
@@ -1,9 +1,22 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import {
BanIcon,
CirclePauseIcon,
CreditCardIcon,
MoreVertical,
ReceiptTextIcon,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
@@ -14,27 +27,59 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { usePlan } from "@/lib/swr/use-billing";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UpgradeButton } from "@/components/ui/upgrade-button";
export default function UpgradePlanContainer() {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const teamInfo = useTeam();
const { plan, isFree, isDataroomsPlus } = usePlan();
const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);
const [cancellationModalOpen, setCancellationModalOpen] =
useState<boolean>(false);
const { currentTeamId } = useTeam();
const {
plan,
isFree,
isDataroomsPlus,
isDataroomsPremium,
isPaused,
isCancelled,
isCustomer,
startsAt,
endsAt,
pauseStartsAt,
pauseEndsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
const analytics = useAnalytics();
const manageSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
return;
}
const teamId = teamInfo?.currentTeam?.id;
const manageSubscription = async ({
type,
}: {
type:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update"
| "cancellation";
}) => {
if (!currentTeamId) return;
setLoading(true);
try {
fetch(`/api/teams/${teamId}/billing/manage`, {
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
method: "POST",
body: JSON.stringify({ type }),
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
const url = await res.json();
@@ -53,48 +98,276 @@ export default function UpgradePlanContainer() {
}
};
const handleUnpauseSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/unpause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to unpause subscription");
}
// Track the unpause event for analytics
analytics.capture("Subscription Unpaused", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription unpaused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const handleReactivateSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/reactivate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to reactivate subscription");
}
// Track the reactivation event for analytics
analytics.capture("Subscription Reactivated", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription reactivated successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const isBillingCycleCurrent = () => {
if (!startsAt || !endsAt) return false;
const currentDate = new Date();
return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);
};
const getDiscountText = () => {
if (!discount || !discount.valid) return null;
let discountText = "";
if (discount.percentOff) {
discountText = `${discount.percentOff}% off`;
} else if (discount.amountOff) {
discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;
}
if (discount.duration === "repeating" && discount.durationInMonths) {
discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? "s" : ""}`;
} else if (discount.duration === "once") {
discountText += " (one-time)";
}
return discountText;
};
const ButtonList = () => {
if (isFree) {
return (
<Link href="/settings/upgrade">
<Button>Upgrade Plan</Button>
</Link>
<div className="flex items-center gap-3">
<UpgradeButton
text=""
customText="Upgrade"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
</div>
);
} else if (isCancelled) {
return (
<Button onClick={handleReactivateSubscription} loading={unpauseLoading}>
Reactivate subscription
</Button>
);
} else {
return (
<Button onClick={manageSubscription} loading={loading}>
Manage Subscription
</Button>
<div className="flex items-center gap-3">
{isPaused ? (
<>
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "cancellation" })}
className="text-red-500"
>
<BanIcon className="h-4 w-4" />
Cancel subscription
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
>
Cancel subscription
</Button>
<Button
variant="outline"
onClick={() =>
manageSubscription({ type: "subscription_update" })
}
loading={loading}
>
Change plan
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "manage" })}
>
<CreditCardIcon className="h-4 w-4" />
Change billing information
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
);
}
};
return (
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Billing</CardTitle>
<CardDescription>
You are currently on the{" "}
<span className="mx-0.5 rounded-full bg-background px-2.5 py-1 text-xs font-bold tracking-normal text-foreground ring-1 ring-gray-800 dark:ring-gray-400">
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}
</span>{" "}
plan.{" "}
<Link
href="/settings/upgrade"
className="text-sm underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
</CardDescription>
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
<>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>
{isDataroomsPremium
? "Premium"
: isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}{" "}
Plan
</CardTitle>
{!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (
<CardDescription>
<span className="font-medium text-foreground">
Current billing cycle:{" "}
</span>
<span className="text-foreground">
{new Date(startsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{" - "}
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isPaused && pauseStartsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription{" "}
{new Date(pauseStartsAt) > new Date()
? "will pause on"
: "paused on"}
:{" "}
</span>
<span className="text-foreground">
{new Date(pauseStartsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isCancelled && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription cancels on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{discount && discount.valid && getDiscountText() && (
<CardDescription>
<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">
🎉 {getDiscountText()} applied
</div>
</CardDescription>
)}
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
<CancellationModal
open={cancellationModalOpen}
onOpenChange={setCancellationModalOpen}
/>
</>
);
}
+12 -1
View File
@@ -106,8 +106,19 @@ export function UpgradePlanModal({
trigger: trigger,
teamId: teamInfo?.currentTeam?.id,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamInfo?.currentTeam?.id) {
fetch(`/api/teams/${teamInfo.currentTeam.id}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
}
}, [open, trigger]);
}, [open, trigger, teamInfo?.currentTeam?.id]);
// Track analytics event when child button is present
const handleUpgradeClick = () => {
@@ -0,0 +1,576 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import React from "react";
import { useTeam } from "@/context/team-context";
import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import {
CheckIcon,
CircleHelpIcon,
Sparkles,
Users2Icon,
XIcon,
} from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { capitalize, cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push("/welcome?type=dataroom-trial");
};
return (
<span
onClick={handleStartTrial}
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
>
Start free data room trial
</span>
);
};
// Feature rendering component
const FeatureItem = ({ feature }: { feature: Feature }) => {
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" : ""}`;
if (feature.isUsers) {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<span>{feature.text}</span>
</div>
{feature.tooltip && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<div className="cursor-help">
<Users2Icon className="h-4 w-4 text-gray-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{feature.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
return (
<div className={cn("text-sm", baseClasses)}>
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
<BadgeTooltip content={feature.tooltip}>
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
</BadgeTooltip>
)}
</div>
</div>
);
};
// Segmented control component for Base/Plus/Premium selection
const PlanSelector = ({
value,
onChange,
}: {
value: "base" | "plus" | "premium";
onChange: (value: "base" | "plus" | "premium") => void;
}) => {
return (
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "base"
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("base")}
>
Base
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "plus"
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("plus")}
>
Plus
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "premium"
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("premium")}
>
Premium
</button>
</div>
);
};
export function UpgradePlanModalWithDiscount({
clickedPlan,
trigger,
open,
setOpen,
highlightItem,
children,
}: {
clickedPlan: PlanEnum;
trigger?: string;
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
highlightItem?: string[];
children?: React.ReactNode;
}) {
const router = useRouter();
const [period, setPeriod] = useState<"yearly" | "monthly">("yearly");
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const {
plan: teamPlan,
isCustomer,
isOldAccount,
isTrial,
isFree,
} = usePlan();
const analytics = useAnalytics();
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
"base" | "plus" | "premium"
>("base");
// Get next higher plan for current user
const getNextHigherPlan = () => {
if (isFree) return PlanEnum.Pro; // Free users see Pro as next
if (teamPlan === "pro") return PlanEnum.Business;
if (teamPlan === "business") return PlanEnum.DataRooms;
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
return null;
};
const nextHigherPlan = getNextHigherPlan();
const plansToShow = useMemo(() => {
switch (clickedPlan) {
case PlanEnum.Pro:
return [PlanEnum.Pro, PlanEnum.Business];
case PlanEnum.Business:
return [PlanEnum.Business, PlanEnum.DataRooms];
case PlanEnum.DataRooms:
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
case PlanEnum.DataRoomsPlus:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
case PlanEnum.DataRoomsPremium:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
default:
return [PlanEnum.Pro, PlanEnum.Business];
}
}, [clickedPlan]);
// Track analytics event when modal is opened
useEffect(() => {
if (open) {
analytics.capture("Upgrade Button Clicked", {
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setDataRoomsPlanSelection("base");
}
}, [open, trigger, teamId]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
trigger: trigger,
teamId,
});
};
// If button is present, clone it and add onClick handler
const buttonChild = React.isValidElement<{
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}>(children)
? React.cloneElement(children, { onClick: handleUpgradeClick })
: children;
// Calculate 30% discounted yearly price
const getDiscountedYearlyPrice = (yearlyPrice: number) => {
return Math.round(yearlyPrice * 0.7); // Round to whole number
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{buttonChild}</DialogTrigger>
<DialogContent
className="max-h-[90vh] min-h-fit overflow-y-auto bg-white p-0 text-foreground dark:bg-gray-900"
style={{
width: "95vw",
maxWidth: "1200px",
}}
>
<div className="flex flex-col lg:flex-row">
{/* Left Sidebar - Discount Information */}
<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">
<div className="mb-2">
<h2 className="text-sm font-bold uppercase text-[#fb7a00]">
SWITCH TO YEARLY
</h2>
</div>
<p className="mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
Save <span className="font-bold text-[#fb7a00]">30%</span> on
yearly plans
</p>
{/* Decorative elements - Holiday section - Centered vertically */}
<div className="relative flex flex-1 flex-col items-center justify-center">
<div className="mb-3 h-16 w-16 text-[#fb7a00] opacity-30">
<Sparkles className="h-full w-full" />
</div>
<div className="flex w-full items-center gap-2">
<div className="h-px flex-1 bg-[#fb7a00]"></div>
<p className="text-sm font-semibold text-[#fb7a00]">NEW YEAR&apos;S</p>
<div className="h-px flex-1 bg-[#fb7a00]"></div>
</div>
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
New Year&apos;s offer
</p>
{/* <div className="absolute bottom-0 h-12 w-12 text-[#fb7a00] opacity-20">
<Sparkles className="h-full w-full" />
</div> */}
</div>
<p className="mt-6 text-center text-xs text-gray-500 dark:text-gray-400">
Ends Jan 15 2026
</p>
</div>
{/* Right Content - Plans */}
<div className="flex-1 p-6">
{/* Monthly/Yearly Toggle */}
<div className="mb-6 flex items-center justify-center">
<span className="mr-2 text-sm">
Monthly{" "}
{period === "monthly" && (
<span className="text-[#fb7a00]"></span>
)}
</span>
<Switch
checked={period === "yearly"}
onCheckedChange={() =>
setPeriod(period === "monthly" ? "yearly" : "monthly")
}
/>
<span className="ml-2 text-sm">Annually</span>
</div>
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
{plansToShow.map((planOption) => {
const isDataRoomsUpgrade = plansToShow.includes(
PlanEnum.DataRooms,
);
// Determine which plan to show based on selection for Data Rooms
let effectivePlan = planOption;
let displayPlanName = planOption;
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
if (dataRoomsPlanSelection === "plus") {
effectivePlan = PlanEnum.DataRoomsPlus;
displayPlanName = PlanEnum.DataRoomsPlus;
} else if (dataRoomsPlanSelection === "premium") {
effectivePlan = PlanEnum.DataRoomsPremium;
displayPlanName = PlanEnum.DataRoomsPremium;
}
}
const planFeatures = getPlanFeatures(effectivePlan, {
period,
});
const planData = PLANS.find((p) => p.name === displayPlanName);
const monthlyPrice = planData?.price.monthly.amount || 0;
const yearlyPrice = planData?.price.yearly.amount || 0;
const discountedYearlyPrice =
getDiscountedYearlyPrice(yearlyPrice);
// Determine if this plan should have orange styling
const isOrangePlan = isFree
? displayPlanName === PlanEnum.Business ||
displayPlanName === PlanEnum.DataRoomsPlus
: displayPlanName === nextHigherPlan;
return (
<div
key={displayPlanName}
className={`relative flex flex-col rounded-lg border ${
isOrangePlan
? "border-[#fb7a00]"
: displayPlanName === PlanEnum.DataRoomsPremium
? "border-gray-900"
: "border-gray-200"
} bg-white p-6 shadow-sm dark:bg-gray-900`}
>
<div className="mb-4 border-b border-gray-200 pb-2">
<div className="flex items-center justify-between">
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
{displayPlanName}
</h3>
</div>
{period === "yearly" &&
(() => {
const monthlyForYear = monthlyPrice * 12;
const yearlyWithDiscount = Math.round(
yearlyPrice * 12 * 0.7,
);
const savings = monthlyForYear - yearlyWithDiscount;
return savings > 0 ? (
<span className="absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]">
Save {savings}/year
</span>
) : null;
})()}
{period === "monthly" && isOrangePlan && (
<span className="absolute right-2 top-2 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white">
{displayPlanName === PlanEnum.Business &&
"Most popular"}
{displayPlanName === PlanEnum.DataRoomsPlus &&
"Best deal"}
</span>
)}
</div>
<div className="mb-2">
{period === "yearly" ? (
// Yearly: Show discounted price with crossed out original
<>
<div className="flex items-baseline gap-2">
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
{discountedYearlyPrice}
</span>
<span className="text-xl text-gray-500 line-through">
{yearlyPrice}
</span>
</div>
<span className="text-gray-500 dark:text-white/75">
/month, billed annually
</span>
</>
) : (
// Monthly: Show regular monthly price (no discount)
<>
<div className="flex items-baseline gap-2">
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
{monthlyPrice}
</span>
</div>
<span className="text-gray-500 dark:text-white/75">
/month
</span>
</>
)}
</div>
{planOption === PlanEnum.DataRooms &&
isDataRoomsUpgrade &&
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
<PlanSelector
value={dataRoomsPlanSelection}
onChange={setDataRoomsPlanSelection}
/>
)}
<p className="mt-4 text-sm text-gray-600 dark:text-white">
{planFeatures.featureIntro}
</p>
<ul className="mb-6 mt-2 space-y-2 text-sm leading-6 text-gray-600 dark:text-muted-foreground">
{planFeatures.features.map((feature, i) => (
<li key={i}>
<FeatureItem
feature={{
...feature,
isHighlighted: highlightItem?.includes(
feature.id,
),
}}
/>
</li>
))}
</ul>
<div className="mt-auto">
<Button
variant={isOrangePlan ? "default" : "outline"}
className={`w-full py-2 text-sm ${
isOrangePlan
? "bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]"
: "bg-gray-800 text-white hover:bg-gray-900 hover:text-white dark:hover:bg-gray-700/80"
}`}
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
period,
isOld: isOldAccount,
});
setSelectedPlan(planOption);
// Apply 30% coupon only for yearly plans
// Uses same logic as retention flow: getCouponFromPlan() in API routes
const applyDiscount = period === "yearly";
if (isCustomer && teamPlan !== "free") {
// For existing customers: Apply coupon directly to subscription (same as retention flow)
fetch(`/api/teams/${teamId}/billing/manage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId,
upgradePlan: true,
applyYearlyDiscount: applyDiscount, // Only true for yearly plans
}),
})
.then(async (res) => {
if (!res.ok) {
const errorData = await res
.json()
.catch(() => null);
const errorMessage =
errorData?.error ||
errorData?.message ||
res.statusText ||
"Failed to upgrade plan";
throw new Error(errorMessage);
}
const url = await res.json();
router.push(url);
})
.catch((err) => {
alert(err.message || err);
setSelectedPlan(null);
});
} else {
// For new customers: Add coupon to Stripe checkout session
fetch(
`/api/teams/${teamId}/billing/upgrade?priceId=${priceId}${applyDiscount ? "&applyYearlyDiscount=true" : ""}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
)
.then(async (res) => {
if (!res.ok) {
const errorData = await res
.json()
.catch(() => null);
const errorMessage =
errorData?.error ||
errorData?.message ||
res.statusText ||
"Failed to start checkout";
throw new Error(errorMessage);
}
const data = await res.json();
const { id: sessionId } = data;
const stripe = await getStripe(isOldAccount);
stripe?.redirectToCheckout({ sessionId });
})
.catch((err) => {
alert(err.message || err);
setSelectedPlan(null);
});
}
}}
>
{selectedPlan === planOption
? "Redirecting to Stripe..."
: `Upgrade to ${displayPlanName} ${capitalize(period)}`}
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-6 flex flex-col items-center text-center text-sm text-muted-foreground">
All plans include unlimited visitors and page by page document
analytics.
<div className="flex items-center gap-2">
<Link
href="/settings/upgrade-holiday-offer"
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
</>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+90 -44
View File
@@ -9,8 +9,7 @@ import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, Users2Icon, CircleHelpIcon } from "lucide-react";
import { toast } from "sonner";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -20,11 +19,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
BadgeTooltip,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
@@ -32,7 +31,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push('/welcome?type=dataroom-trial');
router.push("/welcome?type=dataroom-trial");
};
return (
@@ -40,7 +39,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
onClick={handleStartTrial}
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
>
Start free Data Room trial
Start free data room trial
</span>
);
};
@@ -53,7 +52,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<span>{feature.text}</span>
</div>
{feature.tooltip && (
@@ -76,7 +79,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("text-sm", baseClasses)}>
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
@@ -89,37 +96,48 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
);
};
// Segmented control component for Base/Plus selection
// Segmented control component for Base/Plus/Premium selection
const PlanSelector = ({
value,
onChange,
}: {
value: boolean;
onChange: (value: boolean) => void;
value: "base" | "plus" | "premium";
onChange: (value: "base" | "plus" | "premium") => void;
}) => {
return (
<div className="mt-1 flex w-1/2 rounded-lg border border-gray-200 p-1">
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
!value
value === "base"
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange(false)}
onClick={() => onChange("base")}
>
Base
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value
value === "plus"
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("plus")}
>
Plus
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "premium"
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange(true)}
onClick={() => onChange("premium")}
>
Plus
Premium
</button>
</div>
);
@@ -147,7 +165,9 @@ export function UpgradePlanModal({
const teamId = teamInfo?.currentTeam?.id;
const { plan: teamPlan, isCustomer, isOldAccount, isTrial } = usePlan();
const analytics = useAnalytics();
const [showDataRoomsPlus, setShowDataRoomsPlus] = useState(false);
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
"base" | "plus" | "premium"
>("base");
const plansToShow = useMemo(() => {
switch (clickedPlan) {
@@ -158,7 +178,9 @@ export function UpgradePlanModal({
case PlanEnum.DataRooms:
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
case PlanEnum.DataRoomsPlus:
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
case PlanEnum.DataRoomsPremium:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
default:
return [PlanEnum.Pro, PlanEnum.Business];
}
@@ -171,10 +193,21 @@ export function UpgradePlanModal({
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setShowDataRoomsPlus(false);
setDataRoomsPlanSelection("base");
}
}, [open, trigger]);
}, [open, trigger, teamId]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
@@ -215,20 +248,26 @@ export function UpgradePlanModal({
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
{plansToShow.map((planOption) => {
const planFeatures = getPlanFeatures(planOption, {
period,
showDataRoomsPlus:
planOption === PlanEnum.DataRooms && showDataRoomsPlus,
});
// Get the effective plan name for display
const displayPlanName =
planOption === PlanEnum.DataRooms && showDataRoomsPlus
? PlanEnum.DataRoomsPlus
: planOption;
const isDataRoomsUpgrade = plansToShow.includes(PlanEnum.DataRooms);
// Determine which plan to show based on selection for Data Rooms
let effectivePlan = planOption;
let displayPlanName = planOption;
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
if (dataRoomsPlanSelection === "plus") {
effectivePlan = PlanEnum.DataRoomsPlus;
displayPlanName = PlanEnum.DataRoomsPlus;
} else if (dataRoomsPlanSelection === "premium") {
effectivePlan = PlanEnum.DataRoomsPremium;
displayPlanName = PlanEnum.DataRoomsPremium;
}
}
const planFeatures = getPlanFeatures(effectivePlan, {
period,
});
return (
<div
key={displayPlanName}
@@ -244,7 +283,7 @@ export function UpgradePlanModal({
<div className="mb-4 border-b border-gray-200 pb-2">
<div className="flex items-center justify-between">
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
Papermark {displayPlanName}
{displayPlanName}
</h3>
</div>
<span
@@ -252,7 +291,7 @@ export function UpgradePlanModal({
"absolute right-2 top-2 rounded px-2 py-1 text-xs text-white",
planOption === PlanEnum.Business && "bg-[#fb7a00]",
displayPlanName === PlanEnum.DataRoomsPlus &&
"bg-gray-900 dark:bg-gray-100 dark:text-gray-900",
"bg-gray-800 dark:bg-gray-200 dark:text-gray-900",
)}
>
{planOption === PlanEnum.Business && "Most popular"}
@@ -270,7 +309,7 @@ export function UpgradePlanModal({
}
</span>
<span className="text-gray-500 dark:text-white/75">
/month
/month{period === "yearly" && ", billed annually"}
</span>
</div>
@@ -278,8 +317,8 @@ export function UpgradePlanModal({
isDataRoomsUpgrade &&
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
<PlanSelector
value={showDataRoomsPlus}
onChange={setShowDataRoomsPlus}
value={dataRoomsPlanSelection}
onChange={setDataRoomsPlanSelection}
/>
)}
@@ -313,10 +352,11 @@ export function UpgradePlanModal({
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan(
displayPlanName,
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
period,
);
isOld: isOldAccount,
});
setSelectedPlan(planOption);
if (isCustomer && teamPlan !== "free") {
@@ -373,17 +413,23 @@ export function UpgradePlanModal({
})}
</div>
<div className="flex flex-col items-center text-center text-sm text-muted-foreground">
All plans include unlimited viewers and page by page document
All plans include unlimited visitors and page by page document
analytics.
<div className="flex items-center gap-2">
<Link
href="/settings/upgrade"
href={`/settings/upgrade${
clickedPlan === PlanEnum.Pro
? "?view=documents"
: clickedPlan === PlanEnum.Business
? "?view=business-datarooms"
: ""
}`}
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
{trigger === "sidebar_datarooms" &&
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
@@ -0,0 +1,159 @@
"use client";
import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import Cookies from "js-cookie";
import { CheckIcon, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { usePlan } from "@/lib/swr/use-billing";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { UpgradePlanModal } from "./upgrade-plan-modal";
interface YearlyUpgradeBannerProps {
setShowBanner: Dispatch<SetStateAction<boolean | null>>;
}
export default function YearlyUpgradeBanner({
setShowBanner,
}: YearlyUpgradeBannerProps) {
const router = useRouter();
const { plan: teamPlan } = usePlan();
const handleHideBanner = () => {
setShowBanner(false);
Cookies.set("hideYearlyUpgradeBanner", "yearly-upgrade-banner", {
expires: 7,
});
};
// Get next higher plan
const getNextPlan = () => {
if (teamPlan === "pro") return PlanEnum.Business;
if (teamPlan === "business") return PlanEnum.DataRooms;
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
return null;
};
const nextPlan = getNextPlan();
if (!nextPlan) return null; // Don't show banner if no next plan
return (
<AnimatePresence>
<motion.aside
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className={cn(
"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",
)}
>
<button
type="button"
onClick={handleHideBanner}
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"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-[#fb7a00]" />
<span className="text-lg font-bold text-black dark:text-white">
UPGRADE YOUR PLAN
</span>
</div>
<div className="mb-2">
<p className="text-xs text-gray-500 dark:text-gray-400">
Save up to 35% with yearly plans
</p>
</div>
{/* Next Plan Card - Orange */}
{(() => {
const planData = PLANS.find((p) => p.name === nextPlan);
if (!planData) return null;
const yearlyPrice = planData.price.yearly.amount;
const monthlyPrice = planData.price.monthly.amount;
const monthlyForYear = monthlyPrice * 12;
const yearlyForYear = yearlyPrice * 12;
const savings = monthlyForYear - yearlyForYear;
const planFeatures = getPlanFeatures(nextPlan, {
period: "yearly",
maxFeatures: 4,
});
return (
<div className="rounded-lg border-2 border-[#fb7a00] bg-orange-50 p-3 dark:bg-orange-900/20">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-bold text-[#fb7a00]">
RECOMMENDED
</span>
{savings > 0 && (
<span className="text-xs font-medium text-[#fb7a00]">
Save {savings}/year
</span>
)}
</div>
<p className="mb-1 text-sm font-semibold text-gray-900 dark:text-white">
{nextPlan} plan
</p>
<div className="mb-3 flex items-baseline gap-2">
<span className="text-2xl font-semibold text-gray-900 dark:text-white">
{yearlyPrice}
</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
/mo, billed annually
</span>
</div>
{/* Features */}
<ul className="mb-3 space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
{planFeatures.features.slice(0, 4).map((feature, i) => (
<li key={i} className="flex items-center gap-2">
<CheckIcon className="h-3.5 w-3.5 flex-shrink-0 text-[#fb7a00]" />
<span className="leading-tight">{feature.text}</span>
</li>
))}
</ul>
<UpgradePlanModal
clickedPlan={nextPlan}
trigger="yearly_upgrade_banner"
>
<Button
type="button"
size="sm"
className="w-full bg-[#fb7a00] py-3 leading-tight text-white hover:bg-[#fb7a00]/90"
>
Upgrade to {nextPlan} Yearly
</Button>
</UpgradePlanModal>
</div>
);
})()}
<p
onClick={() => router.push("/settings/upgrade")}
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"
>
Compare all plans
</p>
</motion.aside>
</AnimatePresence>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { useEffect, useRef } from "react";
import { AssistantStatus, type Message } from "ai/react";
import Textarea from "react-textarea-autosize";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useEnterSubmit } from "@/lib/utils/use-enter-submit";
import ArrowUp from "../shared/icons/arrow-up";
export function ChatInput({
status,
error,
input,
setInput,
handleInputChange,
submitMessage,
messages,
}: {
status: AssistantStatus;
error: unknown;
input: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
submitMessage: (e: any) => Promise<void>;
messages: Message[];
}) {
const { formRef, onKeyDown } = useEnterSubmit();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (status === "awaiting_message") {
inputRef.current?.focus();
}
}, [status]);
return (
<div className="relative inset-x-0 bottom-0">
<div className="mx-auto sm:max-w-3xl sm:px-4">
<div className="space-y-4 bg-background px-4 py-4 md:py-4">
<form onSubmit={submitMessage} ref={formRef}>
<div className="relative flex max-h-60 w-full flex-col overflow-hidden rounded-xl bg-background pr-8 ring-1 ring-muted-foreground/50 focus-within:ring-1 focus-within:ring-foreground sm:pr-12">
<Textarea
ref={inputRef}
tabIndex={0}
rows={1}
onKeyDown={onKeyDown}
disabled={status !== "awaiting_message"}
className="min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-[1.3rem] focus:ring-0 sm:text-sm"
value={input}
placeholder="Message Papermark Assistant..."
onChange={handleInputChange}
spellCheck={false}
/>
<div className="absolute bottom-3 right-3">
<Button
type="submit"
disabled={status === "in_progress" || input === ""}
title="Send message"
className="h-10 w-10 rounded-md p-1 md:p-2"
>
<ArrowUp className="h-full w-full" />
<span className="sr-only">Send message</span>
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
);
}
function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
className={cn("h-4 w-4", className)}
{...props}
>
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
</svg>
);
}
-52
View File
@@ -1,52 +0,0 @@
import { type Message } from "ai";
import { Separator } from "@/components/ui/separator";
import Skeleton from "../Skeleton";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import { ChatMessage } from "./chat-message";
export interface ChatList {
messages: Message[];
status: "in_progress" | "awaiting_message";
}
export function ChatList({ messages, status }: ChatList) {
if (!messages.length) {
return null;
}
return (
<div className="relative mx-auto max-w-2xl px-4">
{messages.map((message, index) => (
<div key={index}>
<ChatMessage message={message} />
{index < messages.length - 1 && (
<Separator className="my-4 bg-background" />
)}
</div>
))}
{status === "in_progress" && (
<>
<Separator className="my-4 bg-background" />
<div
key={"loading-message"}
className="group relative mb-4 ml-5 flex items-start whitespace-pre-wrap"
>
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow">
<PapermarkSparkle />
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="select-none font-semibold">
Papermark Assistant
</div>
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</>
)}
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
"use client";
import { type Message } from "ai";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
import Check from "../shared/icons/check";
import Copy from "../shared/icons/copy";
interface ChatMessageActionsProps extends React.ComponentProps<"div"> {
message: Message;
}
export function ChatMessageActions({
message,
className,
...props
}: ChatMessageActionsProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const onCopy = () => {
if (isCopied) return;
copyToClipboard(message.content);
};
return (
<div
className={cn(
"flex items-center justify-end md:absolute md:-top-2 md:right-0 md:hidden",
className,
)}
{...props}
>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? <Check /> : <Copy />}
<span className="sr-only">Copy message</span>
</Button>
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { type Message } from "ai";
import { cn } from "@/lib/utils";
import AlertCircle from "../shared/icons/alert-circle";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import UserRound from "../shared/icons/user-round";
import { ChatMessageActions } from "./chat-message-actions";
// map role to icon and name
const mapMessageRole = {
user: { icon: <UserRound />, name: "You" },
system: { icon: <AlertCircle />, name: "System" },
assistant: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
function: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
data: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
tool: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
};
export interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message, ...props }: ChatMessageProps) {
return (
<div
key={message.id}
className={cn(
"group relative mb-4 flex items-start whitespace-pre-wrap md:ml-5",
)}
{...props}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow",
message.role === "user"
? "bg-background"
: message.role === "system"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
)}
>
{mapMessageRole[message.role].icon}
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="group relative flex w-[calc(100%-50px)] flex-col">
<div className="select-none font-semibold">
{mapMessageRole[message.role].name}
</div>
<div className="prose break-words font-light dark:prose-invert prose-p:leading-relaxed prose-pre:p-0">
<p className="mb-2 last:mb-0">{message.content}</p>
</div>
</div>
<ChatMessageActions className="group-hover:flex" message={message} />
</div>
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import { useInView } from "react-intersection-observer";
import { useAtBottom } from "@/lib/utils/use-at-bottom";
interface ChatScrollAnchorProps {
trackVisibility?: boolean;
}
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const isAtBottom = useAtBottom();
const { ref, entry, inView } = useInView({
trackVisibility,
delay: 100,
rootMargin: "0px 0px -100px 0px",
});
React.useEffect(() => {
if (isAtBottom && trackVisibility && !inView) {
entry?.target.scrollIntoView({
block: "start",
});
}
}, [inView, entry, isAtBottom, trackVisibility]);
return <div ref={ref} className="h-px w-full" />;
}
-128
View File
@@ -1,128 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
type Message,
experimental_useAssistant as useAssistant,
} from "ai/react";
import { nanoid } from "nanoid";
import { BasePlan } from "@/lib/swr/use-billing";
import { cn } from "@/lib/utils";
import { ChatInput } from "./chat-input";
import { ChatList } from "./chat-list";
import { ChatScrollAnchor } from "./chat-scroll-anchor";
import { EmptyScreen } from "./empty-screen";
export interface ChatProps extends React.ComponentProps<"div"> {
initialMessages: Message[];
threadId?: string;
firstPage?: string;
isPublic?: boolean;
userId?: string;
plan?: BasePlan | null;
}
export function Chat({
initialMessages,
threadId,
firstPage,
className,
isPublic,
userId,
plan,
}: ChatProps) {
const {
status,
messages: hookMessages,
input: hookInput,
submitMessage,
handleInputChange,
error,
} = useAssistant({
api: "/api/assistants/chat",
threadId: threadId,
body: {
isPublic: isPublic,
userId: userId,
plan: plan,
},
});
const [combinedMessages, setCombinedMessages] = useState<Message[]>([]);
useEffect(() => {
if (error instanceof Error) {
let content: string = "";
if (isPublic) {
content =
"You have reached your request limit for the day. Sign up for a free account to continue using Papermark Assistant.";
}
if (userId && plan !== "pro") {
content =
"You have reached your request limit for the day. Upgrade to a paid account to continue using Papermark Assistant.";
}
const message: Message = {
role: "system",
content: content,
id: nanoid(),
};
setCombinedMessages((prev) => [...prev, message]);
}
}, [error]);
useEffect(() => {
// Concatenate existing messages with messages from the hook
// and reverse the order so that the newest messages are at the bottom:
const reversedMessages = [...initialMessages].reverse();
setCombinedMessages([...reversedMessages, ...hookMessages]);
}, [initialMessages, hookMessages]);
let isLoading;
if (status === "in_progress") {
isLoading = true;
} else if (status === "awaiting_message") {
isLoading = true;
}
const [_, setInput] = useState<string>(hookInput);
return (
<>
<div
className={cn(
"relative h-[calc(100vh-96px)] overflow-y-auto pb-[20px] pt-24",
className,
)}
>
{combinedMessages.length ? (
<>
<ChatList messages={combinedMessages} status={status} />
{!isPublic ? (
<ChatScrollAnchor trackVisibility={isLoading} />
) : null}
</>
) : (
<EmptyScreen
firstPage={firstPage}
handleInputChange={handleInputChange}
setInput={setInput}
/>
)}
</div>
<ChatInput
status={status}
error={error}
messages={combinedMessages}
input={hookInput}
setInput={setInput}
submitMessage={submitMessage}
handleInputChange={handleInputChange}
/>
</>
);
}
-100
View File
@@ -1,100 +0,0 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Sparkle from "../shared/icons/sparkle";
const exampleMessages = [
{
heading: "Summarize the document",
message: "Summarize the document in a few sentences and bullet points.",
},
{
heading: "Tell me about the team",
message:
"Tell me more about the team behind the company. If there's no team page in the document, then let me know.",
},
{
heading: "Ask a question",
message: "What does the company do?",
},
{
heading: "What is this document about?",
message: "Please tell me in one sentence what this document is about.",
},
];
export function EmptyScreen({
firstPage,
setInput,
handleInputChange,
}: {
firstPage?: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
}) {
const manageInput = (message: string) => {
setInput(message);
handleInputChange(message);
};
return (
<>
<div className="mx-auto w-1/2 md:w-1/3">
<div className="flex items-center justify-center">
{firstPage ? (
firstPage.includes("cloudfront.net") ? (
<img
className="rounded-md object-contain ring-1 ring-gray-700"
src={firstPage}
alt={`Page 1`}
fetchPriority="high"
/>
) : (
<Image
src={firstPage}
width={768}
height={100}
className="rounded-md object-contain ring-1 ring-gray-700"
alt="First page of the document"
/>
)
) : (
<div className="flex w-full items-center justify-center rounded-md">
<p className="text-center text-2xl">
Chat with{" "}
<span className="text-2xl font-bold tracking-tighter ">
Papermark
</span>
&apos;s pitchdeck
</p>
</div>
)}
</div>
<div className="mt-4 flex justify-center text-lg">
What would you like to know?
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-2xl px-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{exampleMessages.map((message, index) => (
<Button
key={index}
variant="outline"
className="h-auto p-4 text-sm"
onClick={() => {
return handleInputChange({
target: { value: message.message },
});
}}
>
{message.heading}
</Button>
))}
</div>
</div>
</>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/conversation-message";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/shared/conversation-message";
export function ConversationListItem(props: any) {
return <ConversationListItemEE {...props} />;
@@ -3,8 +3,7 @@ import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ButtonTooltip } from "@/components/ui/tooltip";
import { ResponsiveButton } from "@/components/ui/responsive-button";
export default function DownloadDataroomButton({
teamId,
@@ -57,16 +56,13 @@ export default function DownloadDataroomButton({
}
};
return (
<ButtonTooltip content="Download Dataroom" sideOffset={4}>
<Button
onClick={downloadDataroom}
variant="outline"
className=""
size="sm"
loading={isLoading}
>
<DownloadIcon />
</Button>
</ButtonTooltip>
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={downloadDataroom}
variant="outline"
size="sm"
loading={isLoading}
/>
);
}
@@ -25,6 +25,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import {
Select,
SelectContent,
@@ -135,10 +136,13 @@ export default function GenerateIndexDialog({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
<FileSlidersIcon />
Generate Index File
</Button>
<ResponsiveButton
icon={<FileSlidersIcon className="h-4 w-4" />}
text="Generate Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -0,0 +1,165 @@
import { useState } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import { ListOrderedIcon } from "lucide-react";
import { toast } from "sonner";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { usePlan } from "@/lib/swr/use-billing";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
interface RebuildIndexButtonProps {
teamId: string;
dataroomId: string;
disabled?: boolean;
}
export default function RebuildIndexButton({
teamId,
dataroomId,
disabled = false,
}: RebuildIndexButtonProps) {
const { isFeatureEnabled } = useFeatureFlags();
const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const hasDataroomsPlan = isDatarooms || isDataroomsPlus || isTrial;
const hasDataroomsPlusPlan = isDataroomsPlus;
// Show button if: feature flag is enabled OR user has datarooms plan or higher
const shouldShowButton = isDataroomIndexEnabled || hasDataroomsPlan;
// Allow usage if: feature flag is enabled OR user has datarooms-plus plan
const canUseFeature = isDataroomIndexEnabled || hasDataroomsPlusPlan;
// Don't render if conditions aren't met
if (!shouldShowButton) {
return null;
}
const handleRebuildIndex = async () => {
if (!canUseFeature) {
toast.error("Upgrade to Data Rooms Plus plan to use this feature.");
return;
}
try {
setIsLoading(true);
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/calculate-indexes`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to rebuild indexes");
}
const result = await response.json();
toast.success(
`Hierarchical indexes rebuilt successfully! Updated ${result.totalUpdated} items (${result.foldersUpdated} folders, ${result.documentsUpdated} documents).`,
);
setIsOpen(false);
// Trigger a page refresh to show updated indexes
window.location.reload();
} catch (error) {
console.error("Error rebuilding indexes:", error);
toast.error(
error instanceof Error ? error.message : "Failed to rebuild indexes",
);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<ListOrderedIcon className="h-4 w-4" />}
text="Rebuild Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
Rebuild Hierarchical Index
</DialogTitle>
<DialogDescription>
Recalculate the hierarchical numbering based on the dataroom
items&apos; current order.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="rounded-lg bg-muted p-4">
<div className="flex items-start gap-3">
<div className="text-sm text-muted-foreground">
<p className="mb-1 font-medium">What this does:</p>
<ul className="list-inside list-disc space-y-1">
<li>
Analyzes the current folder structure and document order
</li>
<li>
Assigns hierarchical numbers (1, 1.1, 1.1.1, 2, 2.1, etc.)
</li>
<li>
Updates the display to show these numbers alongside names
</li>
<li>Maintains the existing order and hierarchy</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
{canUseFeature ? (
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button onClick={handleRebuildIndex} loading={isLoading}>
<ListOrderedIcon className="h-4 w-4" />
Rebuild Index
</Button>
</>
) : (
<UpgradePlanModal
clickedPlan={PlanEnum.DataRoomsPlus}
trigger="datarooms_rebuild_index_button"
highlightItem={["indexing"]}
>
<Button>Upgrade to rebuild index</Button>
</UpgradePlanModal>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -60,9 +60,7 @@ function RemoveDataroomItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(
`Failed to remove dataroom document ${documentId}: ${error.message}`,
);
throw new Error(error.message || "Failed to remove dataroom document");
}
analytics.capture("Dataroom Document Removed", {
team: teamInfo?.currentTeam?.id,
@@ -78,9 +76,7 @@ function RemoveDataroomItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(
`Failed to remove folderId folder ${folderId}: ${error.message}`,
);
throw new Error(error.message || "Failed to remove dataroom folder");
}
analytics.capture("Dataroom folder Removed", {
team: teamInfo?.currentTeam?.id,
+749 -42
View File
@@ -4,25 +4,56 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import {
Brain,
BriefcaseIcon,
BuildingIcon,
FileTextIcon,
FolderKanbanIcon,
FolderIcon,
HomeIcon,
LineChartIcon,
RocketIcon,
ShoppingCartIcon,
Sparkles,
TrendingUpIcon,
XIcon,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
@@ -39,21 +70,219 @@ export function AddDataroomModal({
const [dataroomName, setDataroomName] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(openModal);
const [activeTab, setActiveTab] = useState<string>("create");
const [dataroomType, setDataroomType] = useState<string>("");
const [aiDescription, setAiDescription] = useState<string>("");
const [aiGenerating, setAiGenerating] = useState<boolean>(false);
const [generatedFolders, setGeneratedFolders] = useState<any[] | null>(null);
const [showPreview, setShowPreview] = useState<boolean>(false);
const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(
new Set(),
);
const [editingFolderPath, setEditingFolderPath] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState<string>("");
const teamInfo = useTeam();
const { isFree, isPro } = usePlan();
const analytics = useAnalytics();
const useTemplate = activeTab === "generate";
const useAI = activeTab === "ai";
const dataroomSchema = z.object({
name: z.string().trim().min(3, {
message: "Please provide a dataroom name with at least 3 characters.",
}),
type: z.string().optional(),
});
const dataroomSchemaWithType = z.object({
type: z.enum(
[
"startup-fundraising",
"raising-first-fund",
"ma-acquisition",
"series-a-plus",
"real-estate-transaction",
"fund-management",
"portfolio-management",
"project-management",
"sales-dataroom",
],
{
errorMap: () => ({ message: "Please select a dataroom type." }),
},
),
});
const TEMPLATES = [
{
id: "startup-fundraising",
name: "Startup Fundraising",
icon: RocketIcon,
},
{
id: "series-a-plus",
name: "Series A+",
icon: TrendingUpIcon,
},
{
id: "raising-first-fund",
name: "Raising a Fund",
icon: LineChartIcon,
},
{
id: "ma-acquisition",
name: "M&A / Acquisition",
icon: BriefcaseIcon,
},
{
id: "sales-dataroom",
name: "Sales Data Room",
icon: ShoppingCartIcon,
},
{
id: "real-estate-transaction",
name: "Real Estate",
icon: HomeIcon,
},
{
id: "fund-management",
name: "Fund Management",
icon: BuildingIcon,
},
{
id: "portfolio-management",
name: "Portfolio Management",
icon: FolderKanbanIcon,
},
{
id: "project-management",
name: "Project Management",
icon: FileTextIcon,
},
];
const handleGenerateFolders = async () => {
if (!aiDescription.trim()) {
return toast.error("Please describe what kind of dataroom you want to create.");
}
setAiGenerating(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai-structure`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
description: aiDescription.trim(),
}),
},
);
if (!response.ok) {
const { message } = await response.json();
setAiGenerating(false);
toast.error(message || "Failed to generate folder structure");
return;
}
const { name, folders } = await response.json();
setGeneratedFolders(folders);
// Set the generated name as default, but allow user to edit it
if (name) {
setDataroomName(name);
}
// Initialize all folders as selected
initializeFolderSelection(folders);
setShowPreview(true);
setAiGenerating(false);
} catch (error) {
setAiGenerating(false);
toast.error("Error generating folder structure. Please try again.");
}
};
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
const validation = dataroomSchema.safeParse({ name: dataroomName });
// For AI generation, if we're in preview mode, create the dataroom
if (useAI && showPreview && generatedFolders) {
// Validate dataroom name
if (!dataroomName.trim()) {
return toast.error("Please provide a dataroom name.");
}
if (dataroomName.trim().length < 3) {
return toast.error(
"Please provide a dataroom name with at least 3 characters.",
);
}
// Filter folders based on selection
const filteredFolders = filterSelectedFolders(generatedFolders);
if (filteredFolders.length === 0) {
return toast.error("Please select at least one folder to include.");
}
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: dataroomName.trim(),
folders: filteredFolders,
}),
},
);
if (!response.ok) {
const { message } = await response.json();
setLoading(false);
toast.error(message);
return;
}
const { dataroom } = await response.json();
analytics.capture("Dataroom Generated with AI", {
dataroomName: dataroomName.trim(),
});
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
toast.success("Dataroom successfully generated! 🎉");
router.push(`/datarooms/${dataroom.id}/documents`);
} catch (error) {
setLoading(false);
toast.error("Error creating dataroom. Please try again.");
return;
} finally {
setLoading(false);
setOpen(false);
if (openModal && setOpenModal) setOpenModal(false);
}
return;
}
// Validate based on whether template is enabled
const schema = useTemplate ? dataroomSchemaWithType : dataroomSchema;
const validation = schema.safeParse({
name: useTemplate ? undefined : dataroomName,
type: useTemplate ? dataroomType : undefined,
});
if (!validation.success) {
return toast.error(validation.error.errors[0].message);
}
@@ -61,18 +290,25 @@ export function AddDataroomModal({
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: dataroomName.trim(),
}),
// Use different endpoint based on whether template is enabled
const endpoint = useTemplate
? `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate`
: `/api/teams/${teamInfo?.currentTeam?.id}/datarooms`;
const body = useTemplate
? {
type: dataroomType,
// Name will be taken from template
}
: { name: dataroomName.trim() };
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify(body),
});
if (!response.ok) {
const { message } = await response.json();
@@ -83,10 +319,21 @@ export function AddDataroomModal({
const { dataroom } = await response.json();
analytics.capture("Dataroom Created", { dataroomName: dataroomName });
analytics.capture(
useTemplate ? "Dataroom Generated" : "Dataroom Created",
{
dataroomName: dataroomName,
...(useTemplate && { dataroomType: dataroomType }),
},
);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
toast.success("Dataroom successfully created! 🎉");
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
toast.success(
useTemplate
? "Dataroom successfully created! 🎉"
: "Dataroom successfully created! 🎉",
);
router.push(`/datarooms/${dataroom.id}/documents`);
} catch (error) {
setLoading(false);
@@ -116,38 +363,498 @@ export function AddDataroomModal({
const onOpenChange = (open: boolean) => {
if (!open) {
setOpen(false);
setDataroomName("");
setActiveTab("create");
setDataroomType("");
setAiDescription("");
setGeneratedFolders(null);
setShowPreview(false);
setSelectedFolderPaths(new Set());
setEditingFolderPath(null);
setEditingFolderName("");
} else {
setOpen(true);
}
if (openModal && setOpenModal) setOpenModal(false);
};
// Generate a unique path for each folder
const generateFolderPath = (folder: any, parentPath: string = ""): string => {
const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;
return currentPath;
};
// Update folder name in the folder structure
const updateFolderName = (
folders: any[],
targetPath: string,
newName: string,
currentPath: string = "",
): any[] => {
return folders.map((folder) => {
const folderPath = generateFolderPath(folder, currentPath);
if (folderPath === targetPath) {
// Update this folder's name
return { ...folder, name: newName };
}
// Recursively update subfolders
if (folder.subfolders && folder.subfolders.length > 0) {
return {
...folder,
subfolders: updateFolderName(
folder.subfolders,
targetPath,
newName,
folderPath,
),
};
}
return folder;
});
};
// Handle folder name edit
const handleFolderNameEdit = (path: string, currentName: string) => {
setEditingFolderPath(path);
setEditingFolderName(currentName);
};
// Save edited folder name
const saveFolderName = () => {
if (!editingFolderPath || !generatedFolders) return;
if (editingFolderName.trim()) {
const updatedFolders = updateFolderName(
generatedFolders,
editingFolderPath,
editingFolderName.trim(),
);
setGeneratedFolders(updatedFolders);
// Update selected paths if needed (recalculate paths)
initializeFolderSelection(updatedFolders);
}
setEditingFolderPath(null);
setEditingFolderName("");
};
// Cancel editing
const cancelFolderNameEdit = () => {
setEditingFolderPath(null);
setEditingFolderName("");
};
// Toggle folder selection
const toggleFolderSelection = (path: string, folder: any) => {
const newSelected = new Set(selectedFolderPaths);
if (newSelected.has(path)) {
// If unchecking, remove this folder and all its subfolders
newSelected.delete(path);
const removeSubfolders = (f: any, currentPath: string) => {
if (f.subfolders) {
f.subfolders.forEach((sub: any) => {
const subPath = generateFolderPath(sub, currentPath);
newSelected.delete(subPath);
removeSubfolders(sub, subPath);
});
}
};
removeSubfolders(folder, path);
} else {
// If checking, add this folder
newSelected.add(path);
}
setSelectedFolderPaths(newSelected);
};
// Filter folders based on selection
const filterSelectedFolders = (folders: any[], parentPath: string = ""): any[] => {
return folders
.map((folder) => {
const currentPath = generateFolderPath(folder, parentPath);
const isSelected = selectedFolderPaths.has(currentPath);
if (!isSelected) {
return null;
}
const filteredSubfolders = folder.subfolders
? filterSelectedFolders(folder.subfolders, currentPath)
: undefined;
return {
...folder,
subfolders: filteredSubfolders && filteredSubfolders.length > 0
? filteredSubfolders
: undefined,
};
})
.filter((folder) => folder !== null);
};
// Initialize all folders as selected when preview is shown
const initializeFolderSelection = (folders: any[], parentPath: string = "") => {
const paths = new Set<string>();
const collectPaths = (f: any[], currentPath: string = "") => {
f.forEach((folder) => {
const folderPath = generateFolderPath(folder, currentPath);
paths.add(folderPath);
if (folder.subfolders) {
collectPaths(folder.subfolders, folderPath);
}
});
};
collectPaths(folders);
setSelectedFolderPaths(paths);
};
const renderFolderPreview = (
folders: any[],
indent = 0,
parentPath: string = "",
) => {
return (
<div className="space-y-1">
{folders.map((folder, index) => {
const currentPath = generateFolderPath(folder, parentPath);
const isSelected = selectedFolderPaths.has(currentPath);
return (
<div key={`${parentPath}-${index}`} className="pl-4">
<div className="flex items-center gap-2 py-1">
<div
className="h-4 w-4"
style={{ marginLeft: `${indent * 16}px` }}
/>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleFolderSelection(currentPath, folder)}
id={`folder-${currentPath}`}
/>
<label
htmlFor={`folder-${currentPath}`}
className="flex items-center gap-2 cursor-pointer flex-1"
>
<FolderIcon className="h-4 w-4 text-gray-500" />
{editingFolderPath === currentPath ? (
<Input
value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)}
onBlur={saveFolderName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveFolderName();
} else if (e.key === "Escape") {
e.preventDefault();
cancelFolderNameEdit();
}
}}
className="h-6 text-sm px-2 py-1"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-1 py-0.5 rounded cursor-text"
onClick={(e) => {
e.stopPropagation();
handleFolderNameEdit(currentPath, folder.name);
}}
title="Click to edit folder name"
>
{folder.name}
</span>
)}
</label>
</div>
{folder.subfolders && folder.subfolders.length > 0 && (
<div className="ml-4">
{renderFolderPreview(folder.subfolders, indent + 1, currentPath)}
</div>
)}
</div>
);
})}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Create dataroom</DialogTitle>
<DialogDescription>
Start creating a dataroom with a name.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="dataroom-name" className="opacity-80">
Dataroom Name
</Label>
<Input
id="dataroom-name"
placeholder="ACME Aquisition"
className="mb-4 mt-1 w-full"
onChange={(e) => setDataroomName(e.target.value)}
/>
<DialogFooter>
<Button type="submit" className="h-9 w-full" loading={loading}>
Add new dataroom
</Button>
</DialogFooter>
</form>
<DialogContent className="border-none bg-transparent text-foreground shadow-none sm:max-w-[575px] [&>button]:hidden">
<DialogTitle className="sr-only">Create Dataroom</DialogTitle>
<DialogDescription className="sr-only">
Create a new dataroom or generate one with pre-configured folders
</DialogDescription>
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value);
// Reset AI-related state when switching tabs
if (value !== "ai") {
setAiDescription("");
setGeneratedFolders(null);
setShowPreview(false);
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="create">Create from scratch</TabsTrigger>
<TabsTrigger value="generate">Create from template</TabsTrigger>
<TabsTrigger
value="ai"
className={`flex items-center gap-2 ${
activeTab === "ai"
? "text-orange-500 data-[state=active]:text-orange-500"
: ""
}`}
>
<Sparkles
className={`h-4 w-4 ${
activeTab === "ai" ? "text-orange-500" : "text-current"
}`}
/>
Generate with AI
</TabsTrigger>
</TabsList>
<TabsContent value="create">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
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"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Create dataroom</CardTitle>
<CardDescription>
Start creating a dataroom with a name.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-1">
<Label htmlFor="dataroom-name-create">
Dataroom Name{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Input
id="dataroom-name-create"
placeholder="ACME Acquisition"
value={dataroomName}
onChange={(e) => setDataroomName(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" loading={loading}>
Add new dataroom
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="generate">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
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"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Create dataroom</CardTitle>
<CardDescription>
Create a dataroom with pre-configured folder structure.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-2">
<Label>
Select Template{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<div className="grid grid-cols-3 divide-x divide-y divide-border overflow-hidden rounded-md border ">
{TEMPLATES.map((template) => {
const Icon = template.icon;
const isSelected = dataroomType === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => setDataroomType(template.id)}
className={`relative flex min-h-[120px] flex-col items-center justify-center space-y-3 overflow-hidden p-4 transition-colors ${
isSelected
? "bg-gray-200 dark:bg-gray-800"
: "hover:bg-gray-100 hover:dark:bg-gray-800"
}`}
>
<Icon className="pointer-events-none h-auto w-10 text-foreground" />
<p className="text-sm">{template.name}</p>
</button>
);
})}
</div>
</div>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={!dataroomType}
>
Create new dataroom
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
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"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Generate dataroom with AI</CardTitle>
<CardDescription>
AI will create a unique and proven data room structure for your use case.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!showPreview ? (
<>
<div className="space-y-1">
<Label htmlFor="ai-description">
Describe your dataroom in details{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Textarea
id="ai-description"
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."
value={aiDescription}
onChange={(e) => setAiDescription(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
<Button
type="button"
className="w-full"
loading={aiGenerating}
onClick={handleGenerateFolders}
disabled={!aiDescription.trim() || aiGenerating}
>
Generate data room structure
</Button>
</>
) : (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-1">
<Label htmlFor="dataroom-name-ai">
Dataroom Name{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Input
id="dataroom-name-ai"
placeholder="AI Generated Data Room"
value={dataroomName}
onChange={(e) => setDataroomName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Generated Folder Structure{" "}
<span className="text-xs text-muted-foreground font-normal">
(Select folders to include)
</span>
</Label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={
generatedFolders &&
selectedFolderPaths.size > 0 &&
generatedFolders.every((folder) => {
const path = generateFolderPath(folder);
return selectedFolderPaths.has(path);
})
? true
: false
}
onCheckedChange={(checked) => {
if (checked && generatedFolders) {
initializeFolderSelection(generatedFolders);
} else {
setSelectedFolderPaths(new Set());
}
}}
id="select-all"
/>
<label
htmlFor="select-all"
className="cursor-pointer"
>
Select/Deselect All
</label>
</div>
</div>
<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">
{generatedFolders && renderFolderPreview(generatedFolders)}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setShowPreview(false);
setGeneratedFolders(null);
}}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
loading={loading}
>
Create Dataroom
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
+89 -34
View File
@@ -7,6 +7,10 @@ import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import {
Breadcrumb,
@@ -25,6 +29,70 @@ import {
} from "@/components/ui/dropdown-menu";
import { TruncatedBreadcrumbLink } from "../layouts/breadcrumb";
const BreadcrumbFolderItem = ({
folder,
dataroomId,
isLast,
}: {
folder: any;
dataroomId: string;
isLast: boolean;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
if (isLast) {
return (
<BreadcrumbPage
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbPage>
);
}
return (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</BreadcrumbLink>
);
};
const BreadcrumbDropdownItem = ({
folder,
dataroomId,
}: {
folder: any;
dataroomId: string;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
return (
<DropdownMenuItem>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</DropdownMenuItem>
);
};
function BreadcrumbComponentBase({
name,
dataroomId,
@@ -69,54 +137,41 @@ function BreadcrumbComponentBase({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{folders.slice(0, -2).map((folder, index) => (
<DropdownMenuItem key={index}>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
>
{folder.name}
</Link>
</DropdownMenuItem>
<BreadcrumbDropdownItem
key={index}
folder={folder}
dataroomId={dataroomId}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
className="max-w-[200px] truncate"
>
{folders[folders.length - 2].name}
</Link>
</BreadcrumbLink>
<BreadcrumbFolderItem
folder={folders[folders.length - 2]}
dataroomId={dataroomId}
isLast={false}
/>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="max-w-[200px] truncate">
{folders[folders.length - 1].name}
</BreadcrumbPage>
<BreadcrumbFolderItem
folder={folders[folders.length - 1]}
dataroomId={dataroomId}
isLast={true}
/>
</BreadcrumbItem>
</>
) : (
folders?.map((folder, index) => (
<React.Fragment key={index}>
<BreadcrumbItem>
{index === folders.length - 1 ? (
<BreadcrumbPage className="max-w-[200px] truncate">
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
>
{folder.name}
</Link>
</BreadcrumbLink>
)}
<BreadcrumbFolderItem
folder={folder}
dataroomId={dataroomId}
isLast={index === folders.length - 1}
/>
</BreadcrumbItem>
{index < folders.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
@@ -141,4 +196,4 @@ const BreadcrumbComponent = () => {
return MemoizedBreadcrumbComponent;
};
export { BreadcrumbComponent };
export { BreadcrumbComponent };
+233
View File
@@ -0,0 +1,233 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/router";
import { formatDistanceToNow } from "date-fns";
import { TagColorProps } from "@/lib/types";
import TagBadge from "@/components/links/link-sheet/tags/tag-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DataroomTag = {
tag: {
id: string;
name: string;
color: string;
description: string | null;
};
};
type DataroomWithDetails = {
id: string;
name: string;
internalName?: string | null;
_count: {
documents: number;
views: number;
};
activeLinkCount: number;
lastViewedAt: Date | null;
createdAt: Date;
tags?: DataroomTag[];
};
interface DataroomCardProps {
dataroom: DataroomWithDetails;
}
export default function DataroomCard({ dataroom }: DataroomCardProps) {
const router = useRouter();
const isActive = dataroom.activeLinkCount > 0;
const activeLinkCount = dataroom.activeLinkCount;
const lastViewedAt = dataroom.lastViewedAt;
const hasDocuments = dataroom._count.documents > 0;
const handleButtonClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!hasDocuments) {
// If no documents, go to documents tab
router.push(`/datarooms/${dataroom.id}/documents`);
} else if (!isActive) {
// If has documents but inactive, go to permissions/share
router.push(`/datarooms/${dataroom.id}/permissions`);
}
};
return (
<Card className="group relative overflow-hidden duration-500 hover:border-primary/50">
<Link href={`/datarooms/${dataroom.id}/documents`}>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate text-lg">
{dataroom.internalName || dataroom.name}
</CardTitle>
{dataroom.internalName && (
<p className="truncate text-sm text-muted-foreground mt-0.5">
{dataroom.name}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center gap-1.5"
onClick={(e) => e.preventDefault()}
>
<div
className={`h-2 w-2 rounded-full ${
isActive ? "bg-green-500" : "bg-gray-400"
}`}
/>
<span
className={`text-xs ${
isActive ? "text-green-600" : "text-gray-500"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isActive
? "Dataroom has active links"
: "No active links"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Tags */}
{dataroom.tags && dataroom.tags.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
onClick={(e) => e.preventDefault()}
>
{dataroom.tags.slice(0, 3).map((tagItem) => (
<TagBadge
key={tagItem.tag.id}
name={tagItem.tag.name}
color={tagItem.tag.color as TagColorProps}
withIcon
/>
))}
{dataroom.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{dataroom.tags.length - 3} more
</span>
)}
</div>
)}
</CardHeader>
<CardContent>
{/* Stats List */}
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Documents</div>
<div className="text-sm text-muted-foreground">
{dataroom._count.documents}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Views</div>
<div className="text-sm text-muted-foreground">
{dataroom._count.views}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Active Links</div>
<div className="text-sm text-muted-foreground">
{activeLinkCount}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t pt-3">
<div className="text-xs text-muted-foreground">
{lastViewedAt ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span>
Viewed{" "}
{formatDistanceToNow(new Date(lastViewedAt), {
addSuffix: true,
})}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Last viewed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span>No views yet</span>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
{!hasDocuments ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<span>Add Documents</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add documents to dataroom</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
!isActive && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<span>Share</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Share dataroom</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
</div>
</div>
</CardContent>
</Link>
</Card>
);
}
@@ -20,6 +20,10 @@ import { type DataroomFolderDocument } from "@/lib/swr/use-dataroom";
import { type DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { cn, nFormatter, timeAgo } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import BarChart from "@/components/shared/icons/bar-chart";
import { Button } from "@/components/ui/button";
@@ -61,6 +65,12 @@ export default function DataroomDocumentCard({
theme === "light" || (theme === "system" && systemTheme === "light");
const router = useRouter();
// Get hierarchical display name
const displayName = useHierarchicalDisplayName(
dataroomDocument.document.name,
dataroomDocument.hierarchicalIndex,
);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
@@ -127,7 +137,11 @@ export default function DataroomDocumentCard({
{
method: "DELETE",
},
).then(() => {
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to remove document");
}
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${endpoint}`,
null,
@@ -145,7 +159,7 @@ export default function DataroomDocumentCard({
{
loading: "Removing document...",
success: "Document removed successfully.",
error: "Failed to remove document. Try again.",
error: (err) => err.message || "Failed to remove document. Try again.",
},
);
};
@@ -205,8 +219,11 @@ export default function DataroomDocumentCard({
<div className="flex-col">
<div className="flex items-center">
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{dataroomDocument.document.name}
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+10 -3
View File
@@ -18,10 +18,12 @@ import {
export const DataroomHeader = ({
title,
description,
internalName,
actions,
}: {
title: string;
description: string;
internalName?: string | null;
actions?: React.ReactNode[];
}) => {
const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);
@@ -39,9 +41,14 @@ export const DataroomHeader = ({
<section className="mb-4">
<div className="flex items-center justify-between">
<div className="flex min-h-10 items-center gap-x-2 space-y-1">
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
<div>
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
{internalName || title}
</h1>
{internalName && (
<p className="text-sm text-muted-foreground">{title}</p>
)}
</div>
{dataroom?.enableChangeNotifications ? (
<Tooltip>
<TooltipTrigger asChild>
+3 -2
View File
@@ -618,6 +618,7 @@ export function DataroomItemsList({
setUploads={setUploads}
setRejectedFiles={setRejectedFiles}
dataroomId={dataroomId}
dataroomName={dataroom?.name}
>
{isMobile ? (
<div>
@@ -631,7 +632,7 @@ export function DataroomItemsList({
</Portal>
{mixedItems.length === 0 && (
<div className="flex h-full justify-center">
<EmptyDocuments />
<EmptyDocuments isDataroom={true} />
</div>
)}
</div>
@@ -700,7 +701,7 @@ export function DataroomItemsList({
{mixedItems.length === 0 && (
<div className="flex h-full justify-center">
<EmptyDocuments />
<EmptyDocuments isDataroom={true} />
</div>
)}
</DndContext>
+1 -1
View File
@@ -26,7 +26,7 @@ export const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {
segment: "analytics",
},
{
label: "Q&A Conversations",
label: "Q&A",
href: `/datarooms/${dataroomId}/conversations`,
segment: "conversations",
limited: !limits?.conversationsInDataroom,
+30 -5
View File
@@ -8,6 +8,8 @@ import { E164Number } from "libphonenumber-js";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -21,8 +23,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAnalytics } from "@/lib/analytics";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { PhoneInput } from "../ui/phone-input";
import {
@@ -55,6 +55,26 @@ export function DataroomTrialModal({
const teamInfo = useTeam();
const analytics = useAnalytics();
// Helper function to convert industry to proper dataroom name
const getDataroomName = (industryValue: string) => {
const industryNames: Record<string, string> = {
"finance-banking": "Finance and Banking Data Room",
legal: "Legal Data Room",
"real-estate": "Real Estate Data Room",
technology: "Technology Data Room",
pharmaceuticals: "Pharmaceuticals Data Room",
energy: "Energy Data Room",
manufacturing: "Manufacturing Data Room",
healthcare: "Healthcare Data Room",
consulting: "Consulting and Professional Services Data Room",
government: "Government and Public Sector Data Room",
entertainment: "Entertainment and Media Data Room",
other: "Data Room",
};
return industryNames[industryValue] || "Data Room";
};
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
@@ -66,6 +86,8 @@ export function DataroomTrialModal({
setLoading(true);
const dataroomName = getDataroomName(industry);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,
@@ -75,7 +97,7 @@ export function DataroomTrialModal({
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Dataroom #1",
name: dataroomName,
fullName: name,
companyName,
industry,
@@ -93,13 +115,16 @@ export function DataroomTrialModal({
}
analytics.capture("Dataroom Trial Created", {
dataroomName: "Dataroom #1",
dataroomName: dataroomName,
industry,
companySize,
});
toast.success("Dataroom successfully created! 🎉");
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
await Promise.all([
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
]);
router.push("/datarooms");
} catch (error) {
setLoading(false);
+242 -49
View File
@@ -3,6 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -31,6 +35,9 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -43,18 +50,45 @@ export function ExportVisitsModal({
};
}, []);
const startExport = useCallback(async () => {
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "GET" });
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, dataroomId, groupId]);
const startNewExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -72,10 +106,11 @@ export function ExportVisitsModal({
}
// Trigger the background export job
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
{ method: "POST" },
);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "POST" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -174,11 +209,6 @@ export function ExportVisitsModal({
}
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
@@ -201,6 +231,72 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -209,6 +305,10 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -224,9 +324,9 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<h3 className="text-lg font-semibold">Export Views</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
@@ -247,48 +347,141 @@ export function ExportVisitsModal({
</button>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<div className="text-sm text-gray-600">
Exporting views for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
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"
>
Cancel Export
</Button>
</div>
)}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
<div className="text-sm text-gray-600">
Exports for: {displayName}
</div>
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.groupId && (
<div className="mt-1 text-xs text-gray-500">
Group: {exportJob.groupId}
</div>
)}
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || dataroomName,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
<button
onClick={sendExportEmail}
onClick={startNewExport}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
Start New Export
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
)}
</div>
</div>
);
+37 -6
View File
@@ -9,6 +9,10 @@ import {
useDataroomFoldersTree,
} from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
import { FileTree } from "@/components/ui/nextra-filetree";
@@ -19,6 +23,31 @@ type MixedItem =
| (DataroomFolderWithDocuments & { itemType: "folder" })
| (DataroomFolderWithDocuments["documents"][0] & { itemType: "document" });
const DocumentFileItem = memo(
({
document,
router,
}: {
document: DataroomFolderWithDocuments["documents"][0] & {
itemType: "document";
};
router: any;
}) => {
const documentDisplayName = useHierarchicalDisplayName(
document.document.name,
document.hierarchicalIndex,
);
return (
<FileTree.File
name={documentDisplayName}
onToggle={() => router.push(`/documents/${document.document.id}`)}
/>
);
},
);
DocumentFileItem.displayName = "DocumentFileItem";
const FolderComponent = memo(
({
dataroomId,
@@ -29,6 +58,12 @@ const FolderComponent = memo(
}) => {
const router = useRouter();
// Get hierarchical display names
const folderDisplayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
const mixedItems = useMemo(() => {
const allItems: MixedItem[] = [
...(folder.childFolders || []).map((f) => ({
@@ -57,11 +92,7 @@ const FolderComponent = memo(
);
} else {
return (
<FileTree.File
key={item.id}
name={item.document.name}
onToggle={() => router.push(`/documents/${item.document.id}`)}
/>
<DocumentFileItem key={item.id} document={item} router={router} />
);
}
}),
@@ -87,7 +118,7 @@ const FolderComponent = memo(
return (
<FileTree.Folder
name={folder.name}
name={folderDisplayName}
key={folder.id}
active={isActive}
childActive={isChildActive}
+2
View File
@@ -61,6 +61,7 @@ type DataroomDocumentWithVersion = {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -75,6 +76,7 @@ type DataroomFolderWithDocumentsNew = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
+62 -6
View File
@@ -4,17 +4,47 @@ import { memo, useMemo } from "react";
import { DataroomFolder } from "@prisma/client";
import { HomeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { FileTree } from "@/components/ui/nextra-filetree";
import { buildNestedFolderStructureWithDocs } from "./utils";
const ViewerDocumentFileItem = memo(
({
document,
dataroomIndexEnabled,
}: {
document: DataroomDocumentWithVersion;
dataroomIndexEnabled?: boolean;
}) => {
const documentDisplayName = getHierarchicalDisplayName(
document.name,
document.hierarchicalIndex,
dataroomIndexEnabled || false,
);
return (
<FileTree.File
name={documentDisplayName}
// onToggle={() => router.push(`/documents/${document.id}`)}
/>
);
},
);
ViewerDocumentFileItem.displayName = "ViewerDocumentFileItem";
type DataroomDocumentWithVersion = {
dataroomDocumentId: string;
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -29,6 +59,7 @@ type DataroomFolderWithDocuments = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
@@ -59,25 +90,37 @@ const FolderComponent = memo(
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
}: {
folder: DataroomFolderWithDocuments;
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderPath: Set<string> | null;
dataroomIndexEnabled?: boolean;
}) => {
const router = useRouter();
// Get hierarchical display name for the folder
const folderDisplayName = getHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
dataroomIndexEnabled || false,
);
// Memoize the rendering of the current folder's documents
const documents = useMemo(
() =>
folder.documents.map((doc) => (
<FileTree.File
<ViewerDocumentFileItem
key={doc.id}
name={doc.name}
// onToggle={() => router.push(`/documents/${doc.id}`)}
document={{
...doc,
versions: [], // Not needed for display
}}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[folder.documents, router.query.name],
[folder.documents, dataroomIndexEnabled],
);
// Recursively render child folders if they exist
@@ -90,9 +133,16 @@ const FolderComponent = memo(
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[folder.childFolders, folderId, setFolderId],
[
folder.childFolders,
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
],
);
const isActive = folder.id === folderId;
@@ -109,7 +159,7 @@ const FolderComponent = memo(
}}
>
<FileTree.Folder
name={folder.name}
name={folderDisplayName}
key={folder.id}
active={isActive}
childActive={isChildActive}
@@ -165,11 +215,13 @@ const SidebarFolders = ({
documents,
folderId,
setFolderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
dataroomIndexEnabled?: boolean;
}) => {
const nestedFolders = useMemo(() => {
if (folders) {
@@ -203,6 +255,7 @@ const SidebarFolders = ({
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
))}
</FileTree>
@@ -214,11 +267,13 @@ export function ViewFolderTree({
documents,
setFolderId,
folderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderId: string | null;
dataroomIndexEnabled?: boolean;
}) {
if (!folders) return null;
@@ -228,6 +283,7 @@ export function ViewFolderTree({
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
);
}
+245 -162
View File
@@ -1,17 +1,23 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { useUninvitedMembers } from "@/ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations";
import {
MailCheckIcon,
MoreHorizontalIcon,
PlusCircleIcon,
SendIcon,
UserXIcon,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { useDataroomGroup } from "@/lib/swr/use-dataroom-groups";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -31,8 +37,10 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { InviteViewersModal } from "../../../ee/features/dataroom-invitations/components/invite-viewers-modal";
import { AddGroupMemberModal } from "./add-member-modal";
export default function GroupMemberTable({
@@ -44,13 +52,27 @@ export default function GroupMemberTable({
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { dataroom } = useDataroom();
const { viewerGroupMembers, viewerGroupDomains, viewerGroupAllowAll } =
useDataroomGroup();
const {
uninvitedCount,
uninvitedEmails,
mutate: mutateUninvited,
} = useUninvitedMembers(dataroomId, groupId);
const { isFeatureEnabled } = useFeatureFlags();
const [addMembersOpen, setAddMembersOpen] = useState<boolean>(false);
const [inviteOpen, setInviteOpen] = useState<boolean>(false);
const groupKey = teamId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`
: null;
const handleToggleAllowAll = async () => {
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
{
@@ -78,7 +100,9 @@ export default function GroupMemberTable({
};
const handleRemoveDomain = async (domain: string) => {
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
{
@@ -103,7 +127,9 @@ export default function GroupMemberTable({
const handleRemoveMember = async (id: string) => {
// mutate the data optimistically
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
mutate(
key,
(currentData: { members: any[] } | undefined) => {
@@ -135,131 +161,159 @@ export default function GroupMemberTable({
};
return (
<div className="l">
<div className="mb-2 flex items-center justify-between md:mb-4">
<h2>All members</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={viewerGroupAllowAll}
onCheckedChange={handleToggleAllowAll}
aria-label="Allow all emails"
/>
<span className="text-sm text-muted-foreground">
Allow all emails
</span>
<>
<div className="l">
<div className="mb-2 flex items-center justify-between md:mb-4">
<h2>All members</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={viewerGroupAllowAll}
onCheckedChange={handleToggleAllowAll}
aria-label="Allow all emails"
/>
<span className="text-sm text-muted-foreground">
Allow all emails
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setAddMembersOpen(true)}
className="h-8 gap-1"
disabled={viewerGroupAllowAll}
>
<PlusCircleIcon className="h-4 w-4" />
Add members
</Button>
{isFeatureEnabled("dataroomInvitations") && (
<Button
size="sm"
variant="secondary"
onClick={() => setInviteOpen(true)}
className="relative h-8 gap-1"
>
<SendIcon className="h-4 w-4" />
Share invite
{uninvitedCount > 0 ? (
<Badge
variant="secondary"
className="ml-2 h-5 rounded-full px-2 text-xs font-medium"
>
{uninvitedCount}
</Badge>
) : null}
</Button>
)}
</div>
</div>
<Button
size="sm"
onClick={() => setAddMembersOpen(true)}
className="h-8 gap-1"
disabled={viewerGroupAllowAll}
>
<PlusCircleIcon className="h-4 w-4" />
Add members
</Button>
</div>
</div>
<AddGroupMemberModal
dataroomId={dataroomId}
groupId={groupId}
open={addMembersOpen}
setOpen={setAddMembersOpen}
/>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
{/* <TableHead>Last Viewed</TableHead> */}
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{viewerGroupAllowAll ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
All email addresses are allowed to access this group
</p>
</div>
</TableCell>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
{/* <TableHead>Last Viewed</TableHead> */}
<TableHead className="text-center"></TableHead>
</TableRow>
) : viewerGroupMembers.length === 0 &&
viewerGroupDomains.length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p>No visits yet. Try sharing a link.</p>
</div>
</TableCell>
</TableRow>
) : (
<>
{viewerGroupDomains.length > 0 &&
viewerGroupDomains.map((domain) => (
<TableRow key={domain} className="group/row">
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={"@"} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{domain}
</p>
</TableHeader>
<TableBody>
{viewerGroupAllowAll ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
All email addresses are allowed to access this group
</p>
</div>
</TableCell>
</TableRow>
) : viewerGroupMembers.length === 0 &&
viewerGroupDomains.length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p>No views yet. Try sharing a link.</p>
</div>
</TableCell>
</TableRow>
) : (
<>
{viewerGroupDomains.length > 0 &&
viewerGroupDomains.map((domain) => (
<TableRow key={domain} className="group/row">
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={"@"} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{domain}
</p>
</div>
</div>
</div>
</div>
</TableCell>
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
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"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveDomain(domain)}
>
<XIcon className="h-4 w-4" />
Remove Domain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{viewerGroupMembers ? (
viewerGroupMembers.map((viewer) => (
<TableRow key={viewer.id} className="group/row">
{/* Name */}
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={viewer.viewer.email} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{viewer.viewer.email}
</p>
</TableCell>
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
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"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveDomain(domain)}
>
<XIcon className="h-4 w-4" />
Remove Domain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{viewerGroupMembers ? (
viewerGroupMembers.map((viewer) => {
const latestInvitation = viewer.viewer.invitations?.[0];
return (
<TableRow key={viewer.id} className="group/row">
{/* Name */}
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar
viewerEmail={viewer.viewer.email}
/>
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{viewer.viewer.email}
{latestInvitation && (
<TimestampTooltip
timestamp={latestInvitation.sentAt}
side="right"
rows={["local", "utc"]}
>
<MailCheckIcon className="h-4 w-4 text-blue-500 hover:text-blue-600" />
</TimestampTooltip>
)}
</p>
</div>
</div>
</div>
</div>
</div>
</TableCell>
{/* Last Viewed */}
{/* <TableCell className="text-sm text-muted-foreground">
</TableCell>
{/* Last Viewed */}
{/* <TableCell className="text-sm text-muted-foreground">
<time
dateTime={new Date(viewer.viewer.updatedAt).toISOString()}
>
@@ -268,48 +322,77 @@ export default function GroupMemberTable({
: "-"}
</time>
</TableCell> */}
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
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"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveMember(viewer.id)}
>
<UserXIcon className="h-4 w-4" />
Remove Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
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"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveMember(viewer.id)}
>
<UserXIcon className="h-4 w-4" />
Remove Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
</div>
<AddGroupMemberModal
dataroomId={dataroomId}
groupId={groupId}
open={addMembersOpen}
setOpen={setAddMembersOpen}
/>
{isFeatureEnabled("dataroomInvitations") && (
<InviteViewersModal
open={inviteOpen}
setOpen={(next) => {
setInviteOpen(next);
if (!next) {
mutateUninvited();
}
}}
dataroomId={dataroomId}
dataroomName={dataroom?.name ?? "this dataroom"}
groupId={groupId}
defaultEmails={uninvitedEmails}
onSuccess={() => {
if (groupKey) {
mutate(groupKey);
}
mutateUninvited();
}}
/>
)}
</>
);
}
+367 -114
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTeam } from "@/context/team-context";
import { ItemType, ViewerGroupAccessControls } from "@prisma/client";
@@ -19,15 +19,22 @@ import {
EyeOffIcon,
File,
Folder,
HomeIcon,
} from "lucide-react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroomFoldersTree } from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import CloudDownloadOff from "@/components/shared/icons/cloud-download-off";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -38,10 +45,39 @@ import {
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const PermissionItemName = ({ item }: { item: FileOrFolder }) => {
const { isFeatureEnabled } = useFeatureFlags();
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const displayName = getHierarchicalDisplayName(
item.name,
item.hierarchicalIndex,
isDataroomIndexEnabled,
);
const isRoot = item.id === "__dataroom_root__";
return (
<div className="flex items-center text-foreground">
{isRoot ? (
<HomeIcon className="mr-2 h-5 w-5" />
) : item.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate" style={HIERARCHICAL_DISPLAY_STYLE}>
{displayName}
</span>
</div>
);
};
// Update the FileOrFolder type to include permissions
type FileOrFolder = {
id: string;
name: string;
hierarchicalIndex?: string | null;
subItems?: FileOrFolder[];
permissions: {
view: boolean;
@@ -63,38 +99,35 @@ type ColumnExtra = {
};
const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.getCanExpand() ? (
<Button
variant="ghost"
onClick={row.getToggleExpandedHandler()}
className="h-6 w-6 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : null;
},
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="flex items-center text-foreground">
{row.original.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate">{row.original.name}</span>
</div>
),
cell: ({ row }) => {
const isRoot = row.original.id === "__dataroom_root__";
return (
<div className="flex items-center text-foreground">
{isRoot ? (
<div className="h-6 w-6 shrink-0" />
) : row.getCanExpand() ? (
<Button
variant="ghost"
onClick={row.getToggleExpandedHandler()}
className="mr-1 h-6 w-6 shrink-0 p-0"
disabled={isRoot}
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : (
<div className="mr-1 h-6 w-6 shrink-0" />
)}
<PermissionItemName item={row.original} />
</div>
);
},
},
{
id: "actions",
@@ -160,7 +193,7 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
},
];
// Update the buildTree function to include permissions
// Build tree function to include permissions
const buildTree = (
items: any[],
permissions: ViewerGroupAccessControls[],
@@ -168,9 +201,13 @@ const buildTree = (
): FileOrFolder[] => {
const getPermissions = (id: string) => {
const permission = permissions.find((p) => p.itemId === id);
// If we have permission data loaded, use it. Otherwise default to true for consistency.
const hasPermissionData = permissions.length > 0;
return {
view: permission?.canView ?? false,
download: permission?.canDownload ?? false,
view: permission ? permission.canView : hasPermissionData ? false : true,
download: permission ? permission.canDownload : false,
partialView: false,
partialDownload: false,
};
@@ -189,6 +226,7 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
}));
@@ -197,41 +235,40 @@ const buildTree = (
const folderPermissions = getPermissions(folder.id);
// Calculate view and partialView
const someSubItemViewable = allSubItems.some(
(subItem) => subItem.permissions.view,
);
const allSubItemsViewable = allSubItems.every(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = allSubItems.some(
(subItem) => subItem.permissions.download,
);
const allSubItemsDownloadable = allSubItems.every(
(subItem) => subItem.permissions.download,
);
// Calculate view and partialView for folders
let viewStatus = folderPermissions.view;
let partialView = false;
let downloadStatus = folderPermissions.download;
let partialDownload = false;
folderPermissions.view = folderPermissions.view || someSubItemViewable;
folderPermissions.partialView =
someSubItemViewable && !allSubItemsViewable;
folderPermissions.download =
folderPermissions.download || someSubItemDownloadable;
folderPermissions.partialDownload =
someSubItemDownloadable && !allSubItemsDownloadable;
if (allSubItems.length > 0) {
const viewableItems = allSubItems.filter(
(item) => item.permissions.view,
);
const downloadableItems = allSubItems.filter(
(item) => item.permissions.download,
);
// Propagate view/download permission up if any subitem has view/download permission
folderPermissions.view =
folderPermissions.view ||
allSubItems.some((subItem) => subItem.permissions.view);
folderPermissions.download =
folderPermissions.download ||
allSubItems.some((subItem) => subItem.permissions.download);
viewStatus = viewableItems.length > 0;
partialView =
viewableItems.length > 0 && viewableItems.length < allSubItems.length;
downloadStatus = downloadableItems.length > 0;
partialDownload =
downloadableItems.length > 0 &&
downloadableItems.length < allSubItems.length;
}
result.push({
id: folder.id,
name: folder.name,
hierarchicalIndex: folder.hierarchicalIndex,
subItems: allSubItems,
permissions: folderPermissions,
permissions: {
view: viewStatus,
download: downloadStatus,
partialView,
partialDownload,
},
itemType: ItemType.DATAROOM_FOLDER,
});
});
@@ -248,6 +285,7 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
});
@@ -256,6 +294,57 @@ const buildTree = (
return result;
};
// Build tree with virtual root folder
const buildTreeWithRoot = (
items: any[],
permissions: ViewerGroupAccessControls[],
dataroomName: string = "Dataroom Home",
): FileOrFolder[] => {
// Get all items (folders and root documents)
const allItems = buildTree(items, permissions, null);
// Calculate overall permissions for the virtual root
const calculateRootPermissions = (items: FileOrFolder[]) => {
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.reduce((acc, item) => {
acc.push(item);
if (item.subItems) {
acc.push(...flattenItems(item.subItems));
}
return acc;
}, [] as FileOrFolder[]);
};
const allFlatItems = flattenItems(items);
const viewableItems = allFlatItems.filter((item) => item.permissions.view);
const downloadableItems = allFlatItems.filter(
(item) => item.permissions.download,
);
return {
view: viewableItems.length > 0,
download: downloadableItems.length > 0,
partialView:
viewableItems.length > 0 && viewableItems.length < allFlatItems.length,
partialDownload:
downloadableItems.length > 0 &&
downloadableItems.length < allFlatItems.length,
};
};
const rootPermissions = calculateRootPermissions(allItems);
return [
{
id: "__dataroom_root__",
name: dataroomName,
subItems: allItems,
permissions: rootPermissions,
itemType: ItemType.DATAROOM_FOLDER,
},
];
};
export default function ExpandableTable({
dataroomId,
groupId,
@@ -275,8 +364,17 @@ export default function ExpandableTable({
const [pendingChanges, setPendingChanges] = useState<ItemPermission>({});
const [debouncedPendingChanges] = useDebounce(pendingChanges, 2000);
// Use ref to access current data without dependency
const dataRef = useRef<FileOrFolder[]>([]);
useEffect(() => {
dataRef.current = data;
}, [data]);
const updatePermissions = useCallback(
(id: string, newPermissions: string[]) => {
const isRoot = id === "__dataroom_root__";
const findItemAndParents = (
items: FileOrFolder[],
targetId: string,
@@ -297,7 +395,7 @@ export default function ExpandableTable({
return null;
};
const result = findItemAndParents(data, id);
const result = findItemAndParents(dataRef.current, id);
if (!result) return;
const { item, parents } = result;
@@ -324,6 +422,80 @@ export default function ExpandableTable({
updatedPermissions.view = true;
}
// Handle root-level permissions (affects all items)
if (isRoot) {
setData((prevData) => {
const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.map((currentItem) => {
if (currentItem.id === "__dataroom_root__") {
return {
...currentItem,
permissions: {
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
subItems: currentItem.subItems
? updateAllItems(currentItem.subItems)
: undefined,
};
}
const updatedItem = {
...currentItem,
permissions: {
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
subItems: currentItem.subItems
? updateAllItems(currentItem.subItems)
: undefined,
};
return updatedItem;
});
};
return updateAllItems(prevData);
});
// Collect changes for all items
const collectAllChanges = (items: FileOrFolder[]): ItemPermission => {
let changes: ItemPermission = {};
const processItems = (items: FileOrFolder[]) => {
items.forEach((item) => {
// Don't save the virtual __dataroom_root__ item to database
if (item.id !== "__dataroom_root__") {
changes[item.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: item.itemType,
};
}
if (item.subItems) {
processItems(item.subItems);
}
});
};
processItems(items);
return changes;
};
const rootChanges = collectAllChanges(dataRef.current);
setPendingChanges((prev) => ({
...prev,
...rootChanges,
}));
return;
}
setData((prevData) => {
const updateItemInTree = (items: FileOrFolder[]): FileOrFolder[] => {
return items.map((currentItem) => {
@@ -334,6 +506,7 @@ export default function ExpandableTable({
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
};
@@ -392,6 +565,52 @@ export default function ExpandableTable({
parent: FileOrFolder,
subItems: FileOrFolder[],
): FileOrFolder => {
const isParentRoot = parent.id === "__dataroom_root__";
// For root folder, calculate based on all descendants
const calculatePermissions = (items: FileOrFolder[]) => {
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.reduce((acc, item) => {
if (item.id !== "__dataroom_root__") {
acc.push(item);
}
if (item.subItems) {
acc.push(...flattenItems(item.subItems));
}
return acc;
}, [] as FileOrFolder[]);
};
const allItems = flattenItems(items);
const viewableItems = allItems.filter(
(item) => item.permissions.view,
);
const downloadableItems = allItems.filter(
(item) => item.permissions.download,
);
return {
view: viewableItems.length > 0,
partialView:
viewableItems.length > 0 &&
viewableItems.length < allItems.length,
download: downloadableItems.length > 0,
partialDownload:
downloadableItems.length > 0 &&
downloadableItems.length < allItems.length,
};
};
if (isParentRoot) {
const rootPermissions = calculatePermissions(subItems);
return {
...parent,
permissions: rootPermissions,
subItems,
};
}
// For regular folders
const someSubItemViewable = subItems.some(
(subItem) => subItem.permissions.view,
);
@@ -421,18 +640,21 @@ export default function ExpandableTable({
return updateItemInTree(prevData);
});
// database changes
// Collect changes for database update
const collectChanges = (
item: FileOrFolder,
parents: FileOrFolder[],
): ItemPermission => {
let changes: ItemPermission = {
[item.id]: {
let changes: ItemPermission = {};
// Don't save the virtual __dataroom_root__ item to database
if (item.id !== "__dataroom_root__") {
changes[item.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: item.itemType,
},
};
};
}
// Collect changes for all subitems
const collectSubItemChanges = (
@@ -440,11 +662,14 @@ export default function ExpandableTable({
) => {
if (!subItems) return;
subItems.forEach((subItem) => {
changes[subItem.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: subItem.itemType,
};
// Don't save the virtual __dataroom_root__ item to database
if (subItem.id !== "__dataroom_root__") {
changes[subItem.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: subItem.itemType,
};
}
collectSubItemChanges(subItem.subItems);
});
};
@@ -452,35 +677,40 @@ export default function ExpandableTable({
collectSubItemChanges(item.subItems);
// Ensure all parent folders are viewable if the item is being set to viewable
// and downloadable if the item is being set to downloadable
if (updatedPermissions.view || updatedPermissions.download) {
parents.forEach((parent) => {
changes[parent.id] = {
view: true, // Always enable view for parent folders if child is viewable
download: updatedPermissions.download
? true
: parent.permissions.download, // Always enable download for parent folders if child is downloadable
itemType: parent.itemType,
};
// Don't save the virtual __dataroom_root__ item to database
if (parent.id !== "__dataroom_root__") {
changes[parent.id] = {
view: true,
download: updatedPermissions.download
? true
: parent.permissions.download,
itemType: parent.itemType,
};
}
});
} else {
// If turning off view, recalculate parent permissions
[...parents].reverse().forEach((parent) => {
const otherChildren =
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
[];
const someSubItemViewable = otherChildren.some(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = otherChildren.some(
(subItem) => subItem.permissions.download,
);
// Don't save the virtual __dataroom_root__ item to database
if (parent.id !== "__dataroom_root__") {
const otherChildren =
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
[];
const someSubItemViewable = otherChildren.some(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = otherChildren.some(
(subItem) => subItem.permissions.download,
);
changes[parent.id] = {
view: someSubItemViewable,
download: someSubItemDownloadable,
itemType: parent.itemType,
};
changes[parent.id] = {
view: someSubItemViewable,
download: someSubItemDownloadable,
itemType: parent.itemType,
};
}
});
}
@@ -492,12 +722,12 @@ export default function ExpandableTable({
...collectChanges(item, parents),
}));
},
[data],
[],
);
useEffect(() => {
if (folders && !loading) {
const treeData = buildTree(folders, permissions);
const treeData = buildTreeWithRoot(folders, permissions, "Dataroom Home");
setData(treeData);
}
}, [folders, loading, permissions]);
@@ -554,6 +784,18 @@ export default function ExpandableTable({
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row) => row.subItems,
initialState: {
expanded: {
"0": true, // Always expand the root folder (first row)
},
},
getRowCanExpand: (row) => {
// Root folder is always expanded and cannot be collapsed
if (row.original.id === "__dataroom_root__") {
return true;
}
return (row.subRows?.length ?? 0) > 0;
},
});
if (loading) return <div>Loading...</div>;
@@ -582,24 +824,35 @@ export default function ExpandableTable({
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
paddingLeft: `${row.depth * 1.25 + 1}rem`,
}}
className="py-2 last:flex last:justify-end"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
table.getRowModel().rows.map((row) => {
const isRoot = row.original.id === "__dataroom_root__";
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(isRoot && "bg-blue-50/50 dark:bg-blue-950/50")}
>
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
style={
index === 0
? {
paddingLeft: `${row.depth * 1.25}rem`,
}
: undefined
}
className="py-2 last:flex last:justify-end"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
@@ -5,6 +5,9 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { SidebarFolderTreeSelection } from "@/components/datarooms/folders";
import { Button } from "@/components/ui/button";
import {
@@ -16,9 +19,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { TSelectedFolder } from "../documents/move-folder-modal";
export function MoveToDataroomFolderModal({
@@ -110,7 +110,7 @@ export function MoveToDataroomFolderModal({
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<SidebarFolderTreeSelection
dataroomId={dataroomId}
selectedFolder={selectedFolder}
@@ -133,7 +133,9 @@ export function MoveToDataroomFolderModal({
) : (
<>
Move to{" "}
<span className="font-medium">{selectedFolder.name}</span>
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
</>
)}
</Button>
@@ -0,0 +1,104 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface BulkDownloadSettingsProps {
dataroomId: string;
}
export default function BulkDownloadSettings({
dataroomId,
}: BulkDownloadSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
name: string;
pId: string;
allowBulkDownload: boolean;
}>(
dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,
fetcher,
);
const [isUpdating, setIsUpdating] = useState(false);
const handleBulkDownloadToggle = async (checked: boolean) => {
if (!dataroomId || !teamId || isUpdating) return;
setIsUpdating(true);
toast.promise(
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
allowBulkDownload: checked,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error("Failed to update bulk download settings");
}
await mutateDataroom();
}),
{
loading: "Updating bulk download settings...",
success: "Bulk download settings updated successfully",
error: "Failed to update bulk download settings",
},
);
setIsUpdating(false);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center">Dataroom Download</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label
htmlFor="bulk-download-toggle"
className="text-sm font-medium"
>
Allow bulk download of entire dataroom
</Label>
</div>
<Switch
id="bulk-download-toggle"
checked={dataroomData?.allowBulkDownload ?? true}
onCheckedChange={handleBulkDownloadToggle}
disabled={isUpdating}
/>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6">
<p className="text-sm text-muted-foreground transition-colors">
When enabled, visitors can download all dataroom contents as a single
ZIP file. Individual document and folder downloads will still work
regardless of this setting.
</p>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,238 @@
import Link from "next/link";
import { Dispatch, SetStateAction, useMemo, useState } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import { CircleHelpIcon, Tag } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { useTags } from "@/lib/swr/use-tags";
import { TagColorProps } from "@/lib/types";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { MultiSelect } from "@/components/ui/multi-select-v2";
import { BadgeTooltip } from "@/components/ui/tooltip";
type TagProps = {
id: string;
name: string;
color: string;
description: string | null;
};
function getTagOption(tag: TagProps) {
return {
value: tag.id,
label: tag.name,
icon: (
<Tag
size={20}
className={`rounded-sm border border-gray-200 bg-${tag.color}-100 p-1 dark:text-primary-foreground`}
/>
),
meta: { color: tag.color, description: tag.description },
};
}
interface DataroomTagSectionProps {
dataroomId: string;
teamId: string;
initialTags?: {
tag: {
id: string;
name: string;
color: string;
description: string | null;
};
}[];
}
export default function DataroomTagSection({
dataroomId,
teamId,
initialTags,
}: DataroomTagSectionProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>(
initialTags?.map((t) => t.tag.id) || [],
);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { isFree } = usePlan();
const {
tagCount,
tags: availableTags,
loading: loadingTags,
} = useTags({
query: {
sortBy: "createdAt",
sortOrder: "desc",
},
});
const options = useMemo(
() => availableTags?.map((tag) => getTagOption(tag)),
[availableTags],
);
// Check if there are unsaved changes
const hasChanges = useMemo(() => {
const initialTagIds = initialTags?.map((t) => t.tag.id) || [];
if (selectedValues.length !== initialTagIds.length) return true;
return !selectedValues.every((id) => initialTagIds.includes(id));
}, [selectedValues, initialTags]);
// Callback to handle value change
const handleValueChange = (value: string[]) => {
setSelectedValues(value);
};
// Save tags to API
const handleSave = async () => {
setIsSaving(true);
try {
const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: selectedValues }),
});
if (!res.ok) {
const { error } = await res.json();
toast.error(error);
return;
}
await Promise.all([
mutate(`/api/teams/${teamId}/datarooms`),
mutate(`/api/teams/${teamId}/datarooms?simple=true`),
mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`),
]);
toast.success("Successfully updated tags!");
} catch (error) {
toast.error("Failed to update tags");
console.error("Error updating tags:", error);
} finally {
setIsSaving(false);
}
};
const createTag = async (tag: string) => {
if (isFree && tagCount && tagCount >= 5) {
setShowUpgradeModal(true);
toast.error("You have reached the maximum number of tags.");
return false;
}
const res = await fetch(`/api/teams/${teamId}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: tag }),
});
if (!res.ok) {
const { error } = await res.json();
toast.error(error);
return false;
}
const newTag = await res.json();
await mutate(
`/api/teams/${teamId}/tags?${new URLSearchParams({
sortBy: "createdAt",
sortOrder: "desc",
includeLinksCount: false,
} as Record<string, any>).toString()}`,
);
// Add to selected values - same pattern as link-sheet
setSelectedValues([...selectedValues, newTag.id]);
setIsPopoverOpen(false);
toast.success("Successfully created tag!");
return true;
};
return (
<>
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Tags</CardTitle>
<CardDescription>
Organize your dataroom by adding tags for better categorization and
filtering
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Label htmlFor="dataroom-tags">Tags</Label>
<BadgeTooltip content="Organize datarooms by tags to easily filter and find them">
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
</BadgeTooltip>
</div>
<Link
href={`/settings/tags`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Manage
</Link>
</div>
<div className="mt-2 flex gap-2">
<MultiSelect
loading={loadingTags}
options={options ?? []}
value={selectedValues}
setIsPopoverOpen={setIsPopoverOpen}
isPopoverOpen={isPopoverOpen}
onValueChange={handleValueChange}
placeholder="Select tags..."
maxCount={3}
searchPlaceholder="Search or add tags..."
onCreate={(search) => createTag(search)}
popoverClassName="sm:w-[400px]"
/>
<Button
onClick={handleSave}
loading={isSaving}
disabled={!hasChanges || isSaving}
size="default"
>
Save
</Button>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
<p className="text-sm text-muted-foreground transition-colors">
Tags help you organize and filter datarooms across your workspace.
</p>
</CardFooter>
</Card>
{showUpgradeModal && (
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="create_tag"
open={showUpgradeModal}
setOpen={setShowUpgradeModal}
/>
)}
</>
);
}
@@ -51,7 +51,10 @@ function DeleteDataroomModal({
dataroomName: dataroomName,
dataroomId: dataroomId,
});
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
await Promise.all([
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
]);
router.push("/datarooms");
resolve(null);
} else {
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/card";
import { usePlan } from "@/lib/swr/use-billing";
import useDatarooms from "@/lib/swr/use-datarooms";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
export default function DuplicateDataroom({
dataroomId,
@@ -33,7 +33,7 @@ export default function DuplicateDataroom({
const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);
const { limits } = useLimits();
const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();
const { datarooms: dataRooms } = useDatarooms();
const { datarooms: dataRooms } = useDataroomsSimple();
const numDatarooms = dataRooms?.length ?? 0;
const limitDatarooms = limits?.datarooms ?? 1;
@@ -75,6 +75,7 @@ export default function DuplicateDataroom({
loading: "Copying dataroom...",
success: (dataroom) => {
mutate(`/api/teams/${teamId}/datarooms`);
mutate(`/api/teams/${teamId}/datarooms?simple=true`);
router.push(`/datarooms/${dataroom.id}/documents`);
return "Dataroom copied successfully.";
},
@@ -83,7 +83,7 @@ export default function NotificationSettings({
<CardTitle>
Notifications{" "}
{!isDataroomsPlus && !features?.roomChangeNotifications ? (
<PlanBadge plan="datarooms-plus" />
<PlanBadge plan="data rooms plus" />
) : null}
</CardTitle>
<CardDescription>
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,6 +39,18 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
<BellIcon className="h-4 w-4" />
Notifications
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/downloads`}
className={cn(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("downloads"),
},
)}
>
<DownloadIcon className="h-4 w-4" />
Downloads
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/file-permissions`}
className={cn(
@@ -155,10 +155,10 @@ export function DataroomSortableList({
mutate(`${baseKey}/folders`);
mutate(`${baseKey}/folders?include_documents=true`);
setIsReordering(false);
toast.success("Index saved successfully");
toast.success("Folder order saved successfully");
} catch (error) {
console.error("Failed to save new order:", error);
toast.error("Failed to save index");
toast.error("Failed to save order");
// Optionally, show an error message to the user
} finally {
setIsReordering(false);
@@ -247,7 +247,7 @@ export function DataroomSortableList({
className="gap-x-1"
>
<CheckIcon className="size-4" />
Save index
Save order
</Button>
</Portal>
<DeleteFolderModal />
+4 -4
View File
@@ -1,10 +1,10 @@
import ErrorPage from "next/error";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
import StatsElement from "@/components/documents/stats-element";
import { Skeleton } from "@/components/ui/skeleton";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
export default function StatsCard() {
const { stats, loading, error } = useDataroomStats();
@@ -30,12 +30,12 @@ export default function StatsCard() {
const statistics = [
{
name: "Number of visits",
name: "Number of views",
value: stats?.dataroomViews.length.toString() ?? "0",
active: true,
},
{
name: "Number of documents visits",
name: "Number of documents views",
value: stats?.documentViews.length.toString() ?? "0",
active: true,
},
+9 -46
View File
@@ -7,7 +7,11 @@ import { useTheme } from "next-themes";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
import {
FREE_PLAN_ACCEPTED_FILE_TYPES,
FULL_PLAN_ACCEPTED_FILE_TYPES,
SUPPORTED_DOCUMENT_MIME_TYPES,
} from "@/lib/constants";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { bytesToSize } from "@/lib/utils";
@@ -46,49 +50,8 @@ export default function DocumentUpload({
const { getRootProps, getInputProps } = useDropzone({
accept:
isFree && !isTrial
? {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
}
: {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"application/vnd.ms-powerpoint": [], // ".ppt"
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[], // ".pptx"
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
"application/msword": [], // ".doc"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[], // ".docx"
"application/vnd.oasis.opendocument.text": [], // ".odt"
"image/vnd.dwg": [".dwg"], // ".dwg"
"image/vnd.dxf": [".dxf"], // ".dxf"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
"application/zip": [], // ".zip"
"application/x-zip-compressed": [], // ".zip"
"video/mp4": [], // ".mp4"
"video/quicktime": [], // ".mov"
"video/x-msvideo": [], // ".avi"
"video/webm": [], // ".webm"
"video/ogg": [], // ".ogg"
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
"application/vnd.ms-outlook": [".msg"], // ".msg"
},
? FREE_PLAN_ACCEPTED_FILE_TYPES
: FULL_PLAN_ACCEPTED_FILE_TYPES,
multiple: false,
onDropAccepted: (acceptedFiles) => {
if (acceptedFiles.length === 0) {
@@ -228,8 +191,8 @@ export default function DocumentUpload({
{currentFile
? "Replace file?"
: isFree && !isTrial
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.ods, *.png, *.jpeg, *.jpg`
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods, *.png, *.jpeg, *.jpg`
: `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`}
</p>
</div>
</div>
@@ -12,13 +12,13 @@ import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { useAnalytics } from "@/lib/analytics";
function DeleteItemsModal({
showDeleteItemsModal,
setShowDeleteItemsModal,
@@ -75,9 +75,7 @@ function DeleteItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(
`Failed to delete document ${documentId}: ${error.message}`,
);
throw new Error(error.message || "Failed to delete document");
}
analytics.capture("Document Deleted", {
team: teamInfo?.currentTeam?.id,
@@ -96,9 +94,7 @@ function DeleteItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(
`Failed to delete folder ${folderId}: ${error.message}`,
);
throw new Error(error.message || "Failed to delete folder");
}
analytics.capture("Folder Deleted", {
team: teamInfo?.currentTeam?.id,
@@ -319,4 +315,4 @@ export function useDeleteDocumentsAndFoldersModal({
}),
[setShowDeleteItemsModal, DeleteItemsModalCallback],
);
}
}
@@ -37,7 +37,13 @@ export function useDeleteFolderModal(
{
method: "DELETE",
},
),
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to delete folder");
}
return res;
}),
{
loading: isDataroom ? "Removing folder..." : "Deleting folder...",
success: () => {
@@ -54,9 +60,11 @@ export function useDeleteFolderModal(
? "Folder removed successfully."
: `Folder deleted successfully with ${folderToDelete?._count.documents} documents and ${folderToDelete?._count.childFolders} folders`;
},
error: isDataroom
? "Failed to remove folder."
: "Failed to delete folder. Move documents first.",
error: (err) =>
err.message ||
(isDataroom
? "Failed to remove folder."
: "Failed to delete folder. Move documents first."),
},
);
};
+322 -41
View File
@@ -9,6 +9,7 @@ import { DefaultPermissionStrategy } from "@prisma/client";
import { parsePageId } from "notion-utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import {
@@ -18,6 +19,7 @@ import {
} from "@/lib/documents/create-document";
import { putFile } from "@/lib/files/put-file";
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
import { getNotionPageIdFromSlug } from "@/lib/notion/utils";
import { usePlan } from "@/lib/swr/use-billing";
import { useDataroom } from "@/lib/swr/use-dataroom";
import useLimits from "@/lib/swr/use-limits";
@@ -72,6 +74,8 @@ export function AddDocumentModal({
const [isOpen, setIsOpen] = useState<boolean | undefined>(undefined);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [notionLink, setNotionLink] = useState<string | null>(null);
const [webLink, setWebLink] = useState<string | null>(null);
const [uploadMode, setUploadMode] = useState<"file" | "link">("file");
const [showGroupPermissions, setShowGroupPermissions] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<
{
@@ -82,7 +86,7 @@ export function AddDocumentModal({
>([]);
const teamInfo = useTeam();
const { canAddDocuments } = useLimits();
const { plan, isFree, isTrial } = usePlan();
const { plan, isFree, isTrial, isPaused } = usePlan();
const { dataroom } = useDataroom();
const teamId = teamInfo?.currentTeam?.id as string;
@@ -204,6 +208,20 @@ export function AddDocumentModal({
): Promise<void> => {
event.preventDefault();
// Check if team is paused
if (isPaused) {
toast.error(
"Your subscription is paused. Resume your subscription to upload documents.",
{
action: {
label: "Go to Billing",
onClick: () => router.push("/settings/billing"),
},
},
);
return;
}
// Check if the file is chosen
if (!currentFile) {
toast.error("Please select a file to upload.");
@@ -382,22 +400,55 @@ export function AddDocumentModal({
): Promise<void> => {
event.preventDefault();
// Check if team is paused
if (isPaused) {
toast.error(
"Your subscription is paused. Resume your subscription to upload documents.",
{
action: {
label: "Go to Billing",
onClick: () => router.push("/settings/billing"),
},
},
);
return;
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
return;
}
const validateNotionPageURL = parsePageId(notionLink);
// Check if it's a valid URL or not by Regx
const isValidURL =
/^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/;
// Check if the field is empty or not
if (!notionLink) {
toast.error("Please enter a Notion link to proceed.");
return; // prevent form from submitting
}
if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(notionLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
// Try to validate the Notion page URL
let validateNotionPageId = parsePageId(notionLink);
// If parsePageId fails, try to get page ID from slug
if (validateNotionPageId === null) {
try {
const pageId = await getNotionPageIdFromSlug(notionLink);
validateNotionPageId = pageId || undefined;
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}
if (!validateNotionPageId) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
@@ -503,9 +554,147 @@ export function AddDocumentModal({
}
};
const handleWebLinkUpload = async (
event: FormEvent<HTMLFormElement>,
): Promise<void> => {
event.preventDefault();
// Check if user is on a free plan (not trial)
if (isFree && !isTrial) {
toast.error("Web links are available on Pro plan and above.");
return;
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
return;
}
// Check if the field is empty or not
if (!webLink) {
toast.error("Please enter a website URL to proceed.");
return;
}
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(webLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
try {
setUploading(true);
// Extract domain name from URL for the document name
let linkName = "Web Link";
try {
const url = new URL(webLink);
linkName = url.hostname.replace("www.", "");
} catch (e) {
// Use default name if URL parsing fails
}
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/documents`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: linkName,
url: webLink,
numPages: 1,
type: "link",
createLink: false,
folderPathName: currentFolderPath?.join("/"),
}),
},
);
if (!response.ok) {
const { error } = await response.json();
toast.error(error);
return;
}
const document = await response.json();
if (isDataroom && dataroomId) {
const dataroomResponse = await addDocumentToDataroom({
documentId: document.id,
folderPathName: currentFolderPath?.join("/"),
});
if (dataroomResponse?.ok) {
const dataroomDocument =
(await dataroomResponse.json()) as DataroomDocument & {
dataroom: {
_count: { viewerGroups: number; permissionGroups: number };
};
};
await applyUnifiedPermissionsToDocument(
document,
dataroomDocument,
currentFolderPath,
);
}
analytics.capture("Document Added", {
documentId: document.id,
name: document.name,
numPages: document.numPages,
path: router.asPath,
type: "link",
teamId: teamId,
dataroomId: dataroomId,
$set: {
teamId: teamId,
teamPlan: plan,
},
});
return;
}
if (!newVersion) {
toast.success("Web link added. Redirecting to document page...");
analytics.capture("Document Added", {
documentId: document.id,
name: document.name,
fileSize: null,
path: router.asPath,
type: "link",
teamId: teamId,
$set: {
teamId: teamId,
teamPlan: plan,
},
});
// redirect to the document page
router.push("/documents/" + document.id);
}
} catch (error) {
setUploading(false);
toast.error("An error occurred while processing the web link.");
console.error("An error occurred while processing the web link: ", error);
} finally {
setUploading(false);
setIsOpen(false);
}
};
const clearModelStates = () => {
currentFile !== null && setCurrentFile(null);
notionLink !== null && setNotionLink(null);
webLink !== null && setWebLink(null);
setUploadMode("file");
setIsOpen(!isOpen);
setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen);
};
@@ -562,7 +751,7 @@ export function AddDocumentModal({
</CardTitle>
<CardDescription>
{newVersion ? (
`After you upload a new version, the existing links will remain the unchanged.`
`After you upload a new version, the existing links will remain unchanged.`
) : (
<span>
After you upload the document, create a shareable link.{" "}
@@ -584,49 +773,141 @@ export function AddDocumentModal({
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<form
encType="multipart/form-data"
onSubmit={handleFileUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
{uploadMode === "file" ? (
<form
encType="multipart/form-data"
onSubmit={handleFileUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
</div>
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !currentFile}
loading={uploading}
>
{uploading ? "Uploading..." : "Upload Document"}
</Button>
</div>
{!newVersion && (
<div className="flex justify-center">
<p className="text-sm text-muted-foreground">
Want to{" "}
<button
type="button"
className="underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={(e) => {
e.stopPropagation();
document
.getElementById("upload-multi-files-zone")
?.click();
clearModelStates();
}}
>
upload multiple files
</button>{" "}
or{" "}
{isFree && !isTrial ? (
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger={"add_web_link_document"}
>
<button
type="button"
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
>
share link as a document
</button>
</UpgradePlanModal>
) : (
<button
type="button"
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={(e) => {
e.preventDefault();
setUploadMode("link");
}}
>
share link as a document
</button>
)}
?
</p>
</div>
)}
</form>
) : (
<form
encType="multipart/form-data"
onSubmit={handleWebLinkUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<Label htmlFor="web-link">Website URL</Label>
<div className="mt-2">
<input
type="text"
name="web-link"
id="web-link"
placeholder="https://example.com"
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"
value={webLink || ""}
onChange={(e) => setWebLink(e.target.value)}
/>
</div>
{/* <small className="text-xs text-muted-foreground">
The page will be captured and converted to a document format.
</small> */}
</div>
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !webLink}
loading={uploading}
>
{uploading ? "Saving..." : "Save Web Link"}
</Button>
</div>
</div>
{!newVersion ? (
<div className="flex justify-center">
<button
type="button"
className="text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
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"
onClick={(e) => {
e.stopPropagation();
document
.getElementById("upload-multi-files-zone")
?.click();
clearModelStates();
e.preventDefault();
setUploadMode("file");
}}
>
Want to upload multiple files?
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
Back to file upload
</button>
</div>
) : null}
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !currentFile}
loading={uploading}
>
{uploading ? "Uploading..." : "Upload Document"}
</Button>
</div>
</form>
</form>
)}
</CardContent>
</Card>
</TabsContent>
@@ -15,7 +15,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import useDatarooms from "@/lib/swr/use-datarooms";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import {
Select,
@@ -46,7 +46,7 @@ export function AddToDataroomModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { datarooms } = useDatarooms();
const { datarooms } = useDataroomsSimple();
const handleSubmit = async (event: any) => {
event.preventDefault();
@@ -88,7 +88,7 @@ export function AddToDataroomModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="max-w-[90vw] sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>
<span className="font-bold">{documentName}</span>
@@ -98,18 +98,21 @@ export function AddToDataroomModal({
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="min-w-fit">
<SelectTrigger className="w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[380px] max-w-[90vw]">
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
<span className="line-clamp-1 break-words">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -127,15 +130,15 @@ export function AddToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<>
Add to{" "}
<span className="font-medium">
<span className="flex w-full max-w-[350px] items-center justify-center truncate">
Add to
<span className="ml-1 line-clamp-1 truncate font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</>
</span>
)}
</Button>
</DialogFooter>
@@ -16,7 +16,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import useDatarooms from "@/lib/swr/use-datarooms";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import {
Select,
@@ -48,7 +48,7 @@ export function AddFolderToDataroomModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { datarooms } = useDatarooms();
const { datarooms } = useDataroomsSimple();
const handleSubmit = async (event: any) => {
event.preventDefault();
@@ -114,18 +114,21 @@ export function AddFolderToDataroomModal({
<DialogDescription>Add your folder to a dataroom.</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="min-w-fit">
<SelectTrigger className="w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[380px] max-w-[90vw]">
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
<span className="break-words line-clamp-1">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -143,15 +146,15 @@ export function AddFolderToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<>
Add to{" "}
<span className="font-medium">
<span className="flex items-center justify-center w-full max-w-[350px] truncate">
Add to
<span className="font-medium truncate line-clamp-1 ml-1">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</>
</span>
)}
</Button>
</DialogFooter>
@@ -0,0 +1,231 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { uploadImage } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form-hook";
import { Input } from "@/components/ui/input";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters"),
content: z.any().optional(),
pages: z.array(z.number()).min(1, "At least one page must be selected"),
isVisible: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface AnnotationFormProps {
documentId: string;
teamId: string;
numPages: number;
annotation?: any;
onSuccess: () => void;
}
export function AnnotationForm({
documentId,
teamId,
numPages,
annotation,
onSuccess,
}: AnnotationFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [editorContent, setEditorContent] = useState(
annotation?.content || { type: "doc", content: [] },
);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: annotation?.title || "",
content: annotation?.content || null,
pages: annotation?.pages || [],
isVisible:
annotation?.isVisible !== undefined ? annotation.isVisible : true,
},
});
const pageOptions = Array.from({ length: numPages }, (_, i) => i + 1);
const handleImageUpload = async (file: File): Promise<string> => {
try {
// Upload the image using the existing uploadImage utility
const imageUrl = await uploadImage(file, "assets");
// Don't save to database here - let the form submission handle it
// Images will be embedded in the rich text content and parsed when saving
return imageUrl;
} catch (error) {
console.error("Failed to upload image:", error);
throw new Error("Failed to upload image");
}
};
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const url = annotation
? `/api/teams/${teamId}/documents/${documentId}/annotations/${annotation.id}`
: `/api/teams/${teamId}/documents/${documentId}/annotations`;
const method = annotation ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...values,
content: editorContent,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to save annotation");
}
toast.success(
annotation
? "Annotation updated successfully"
: "Annotation created successfully",
);
onSuccess();
} catch (error) {
console.error("Error saving annotation:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save annotation",
);
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter annotation title" {...field} />
</FormControl>
<FormDescription>
A brief title for this annotation that viewers will see.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pages"
render={({ field }) => (
<FormItem>
<FormLabel>Pages</FormLabel>
<FormDescription>
Select which pages this annotation should appear on.
</FormDescription>
<div className="mt-2 grid grid-cols-10 gap-2">
{pageOptions.map((page) => (
<div key={page} className="flex items-center space-x-2">
<Checkbox
id={`page-${page}`}
checked={field.value.includes(page)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, page]);
} else {
field.onChange(field.value.filter((p) => p !== page));
}
}}
/>
<label
htmlFor={`page-${page}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{page}
</label>
</div>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Content</FormLabel>
<FormDescription>
Add rich text content and images to help explain this part of the
document.
</FormDescription>
<RichTextEditor
content={editorContent}
onChange={setEditorContent}
placeholder="Add your annotation content here..."
onImageUpload={handleImageUpload}
/>
</div>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Visible to viewers</FormLabel>
<FormDescription>
When enabled, viewers will be able to see this annotation when
viewing the document.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type="submit" disabled={isLoading}>
{isLoading && <LoadingSpinner className="mr-2 h-4 w-4" />}
{annotation ? "Update Annotation" : "Create Annotation"}
</Button>
</div>
</form>
</Form>
);
}
@@ -0,0 +1,340 @@
"use client";
import { useState } from "react";
import { Edit2, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useAnnotations } from "@/lib/swr/use-annotations";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { AnnotationForm } from "./annotation-form";
interface AnnotationSheetProps {
documentId: string;
teamId: string;
numPages?: number;
trigger?: React.ReactNode;
}
export function AnnotationSheet({
documentId,
teamId,
numPages = 1,
trigger,
}: AnnotationSheetProps) {
const { annotations, mutate } = useAnnotations(documentId, teamId);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingAnnotation, setEditingAnnotation] = useState<any>(null);
const handleDelete = async (annotationId: string) => {
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Failed to delete annotation");
}
toast.success("Annotation deleted successfully");
mutate();
} catch (error) {
console.error("Error deleting annotation:", error);
toast.error("Failed to delete annotation");
}
};
const handleToggleVisibility = async (
annotationId: string,
isVisible: boolean,
) => {
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isVisible: !isVisible,
}),
},
);
if (!response.ok) {
throw new Error("Failed to update annotation");
}
toast.success(
`Annotation ${!isVisible ? "shown" : "hidden"} successfully`,
);
mutate();
} catch (error) {
console.error("Error updating annotation:", error);
toast.error("Failed to update annotation");
}
};
const handleFormSuccess = () => {
setIsCreateOpen(false);
setEditingAnnotation(null);
mutate();
};
return (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
{trigger || (
<Button variant="outline" size="sm">
Add Annotations
</Button>
)}
</SheetTrigger>
<SheetContent className="w-[600px] sm:max-w-[600px]">
<SheetHeader>
<SheetTitle>Document Annotations</SheetTitle>
<SheetDescription>
Manage annotations that viewers can see when viewing this document.
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{annotations?.length || 0} annotation
{annotations?.length !== 1 ? "s" : ""}
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Annotation
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Annotation</DialogTitle>
<DialogDescription>
Add a new annotation that viewers can see when viewing this
document.
</DialogDescription>
</DialogHeader>
<AnnotationForm
documentId={documentId}
teamId={teamId}
numPages={numPages}
onSuccess={handleFormSuccess}
/>
</DialogContent>
</Dialog>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
{annotations && annotations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-muted p-3">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-lg font-medium">No annotations yet</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
Create your first annotation to help viewers understand your
document better.
</p>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create First Annotation
</Button>
</div>
) : (
<div className="space-y-3">
{annotations?.map((annotation) => (
<div
key={annotation.id}
className="space-y-3 rounded-lg border p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{annotation.title}</h4>
{!annotation.isVisible && (
<Badge variant="secondary">Hidden</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Pages: {annotation.pages.join(", ")}</span>
<span></span>
<span>
Created by{" "}
{annotation.createdBy?.name ||
annotation.createdBy?.email}
</span>
<span></span>
<span>
{new Date(
annotation.createdAt,
).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleToggleVisibility(
annotation.id,
annotation.isVisible,
)
}
>
{annotation.isVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Dialog
open={editingAnnotation?.id === annotation.id}
onOpenChange={(open) =>
setEditingAnnotation(open ? annotation : null)
}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Annotation</DialogTitle>
<DialogDescription>
Update this annotation.
</DialogDescription>
</DialogHeader>
<AnnotationForm
documentId={documentId}
teamId={teamId}
numPages={numPages}
annotation={annotation}
onSuccess={handleFormSuccess}
/>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Annotation
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this annotation?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(annotation.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Preview of annotation content */}
{annotation.content && (
<div className="rounded bg-muted/50 p-3 text-sm text-muted-foreground">
<div className="line-clamp-3">
{/* Simple text preview */}
{typeof annotation.content === "object" &&
annotation.content.content
? annotation.content.content
.filter(
(node: any) => node.type === "paragraph",
)
.map((node: any) =>
node.content
?.filter(
(textNode: any) =>
textNode.type === "text",
)
.map((textNode: any) => textNode.text)
.join(" "),
)
.join(" ")
: "No content"}
</div>
{annotation.images && annotation.images.length > 0 && (
<div className="mt-2 flex gap-1">
{annotation.images.slice(0, 3).map((image) => (
<img
key={image.id}
src={image.url}
alt={image.filename}
className="h-8 w-8 rounded border object-cover"
/>
))}
{annotation.images.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded border bg-muted text-xs">
+{annotation.images.length - 3}
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</ScrollArea>
</div>
</SheetContent>
</Sheet>
);
}

Some files were not shown because too many files have changed in this diff Show More