Compare commits

...
Author SHA1 Message Date
Cursor Agentandmarcftone 292dd88fb4 refactor: use react-hotkeys-hook for Command+Enter shortcut
- Replace manual keyboard event listeners with useHotkeys hook
- Use 'mod+enter' which handles Cmd on Mac and Ctrl on Windows/Linux
- Enable hotkeys on form tags to work within input fields

Co-authored-by: marcftone <marcftone@gmail.com>
2026-02-02 23:16:22 +00:00
Cursor Agentandmarcftone 844fd1f0a2 feat: add Command+Enter keyboard shortcut to save/update links
- Add keyboard shortcut (Cmd+Enter on Mac, Ctrl+Enter on Windows/Linux) to submit link forms
- Applied to document link sheet and dataroom link sheet
- The shortcut triggers form submission when the sheet is open

Co-authored-by: marcftone <marcftone@gmail.com>
2026-02-02 07:37:37 +00:00
Marc SeitzandGitHub 02433f85ad Merge pull request #2023 from mfts/fix/layout
feat: close sidebar on dataroom
2026-01-30 19:17:33 +11:00
Marc SeitzandGitHub 859f4b4a6a Merge pull request #2024 from mfts/fix/link-layout
feat: update link table to make it fit better on all screens
2026-01-30 19:12:16 +11:00
Marc Seitz 3fa622a9b7 fix: hydration error 2026-01-30 19:09:13 +11:00
Marc Seitz ede83d3373 feat: update link table to make it fit better on all screens 2026-01-30 19:05:34 +11:00
Marc Seitz 715f2c83f4 feat: close sidebar on dataroom 2026-01-30 15:07:10 +11:00
Marc SeitzandGitHub 119c47dd46 Merge pull request #2022 from mfts/feat/replace-react-pdf
refactor: replace react-pdf page count with libpdf
2026-01-30 13:42:26 +11:00
Marc Seitz 5a0a6a6d60 fix 2026-01-30 09:01:44 +11:00
Marc SeitzandGitHub 6acbff04ea Merge pull request #2021 from mfts/feat/toc
feat: add improved table of content loading
2026-01-30 08:57:47 +11:00
Marc Seitz df9534cb72 refactor: replace react-pdf page count with libpdf 2026-01-30 08:56:53 +11:00
Marc Seitz 41c9892d09 fix: load images 2026-01-30 08:43:55 +11:00
Marc Seitz 0bbaf6683f feat: add improved table of content loading 2026-01-30 00:06:34 +11:00
Marc SeitzandGitHub b22ee77134 Merge pull request #2019 from mfts/chore/depss
chore: update dependencies
2026-01-29 09:52:09 +11:00
Marc Seitz 51851f6277 fix: limit query by team 2026-01-29 09:39:40 +11:00
Marc Seitz 06efef9d4b feat: improve visitor query 2026-01-29 09:05:55 +11:00
Marc Seitz 1794cd35f3 chore: update dependencies 2026-01-29 08:58:46 +11:00
Marc Seitz e6b8adf73e chore: add skills 2026-01-29 08:58:33 +11:00
Marc Seitz d721f39813 chore: update dependencies 2026-01-29 08:45:16 +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
821 changed files with 76324 additions and 13924 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
---
name: vercel-react-best-practices
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
license: MIT
metadata:
author: vercel
version: "1.0.0"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
| 3 | Server-Side Performance | HIGH | `server-` |
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
| 6 | Rendering Performance | MEDIUM | `rendering-` |
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
## Quick Reference
### 1. Eliminating Waterfalls (CRITICAL)
- `async-defer-await` - Move await into branches where actually used
- `async-parallel` - Use Promise.all() for independent operations
- `async-dependencies` - Use better-all for partial dependencies
- `async-api-routes` - Start promises early, await late in API routes
- `async-suspense-boundaries` - Use Suspense to stream content
### 2. Bundle Size Optimization (CRITICAL)
- `bundle-barrel-imports` - Import directly, avoid barrel files
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
- `bundle-defer-third-party` - Load analytics/logging after hydration
- `bundle-conditional` - Load modules only when feature is activated
- `bundle-preload` - Preload on hover/focus for perceived speed
### 3. Server-Side Performance (HIGH)
- `server-auth-actions` - Authenticate server actions like API routes
- `server-cache-react` - Use React.cache() for per-request deduplication
- `server-cache-lru` - Use LRU cache for cross-request caching
- `server-dedup-props` - Avoid duplicate serialization in RSC props
- `server-serialization` - Minimize data passed to client components
- `server-parallel-fetching` - Restructure components to parallelize fetches
- `server-after-nonblocking` - Use after() for non-blocking operations
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
- `client-swr-dedup` - Use SWR for automatic request deduplication
- `client-event-listeners` - Deduplicate global event listeners
- `client-passive-event-listeners` - Use passive listeners for scroll
- `client-localstorage-schema` - Version and minimize localStorage data
### 5. Re-render Optimization (MEDIUM)
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
- `rerender-memo` - Extract expensive work into memoized components
- `rerender-memo-with-default-value` - Hoist default non-primitive props
- `rerender-dependencies` - Use primitive dependencies in effects
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
- `rerender-derived-state-no-effect` - Derive state during render, not effects
- `rerender-functional-setstate` - Use functional setState for stable callbacks
- `rerender-lazy-state-init` - Pass function to useState for expensive values
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
- `rerender-transitions` - Use startTransition for non-urgent updates
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
### 6. Rendering Performance (MEDIUM)
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
- `rendering-content-visibility` - Use content-visibility for long lists
- `rendering-hoist-jsx` - Extract static JSX outside components
- `rendering-svg-precision` - Reduce SVG coordinate precision
- `rendering-hydration-no-flicker` - Use inline script for client-only data
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
- `rendering-activity` - Use Activity component for show/hide
- `rendering-conditional-render` - Use ternary, not && for conditionals
- `rendering-usetransition-loading` - Prefer useTransition for loading state
### 7. JavaScript Performance (LOW-MEDIUM)
- `js-batch-dom-css` - Group CSS changes via classes or cssText
- `js-index-maps` - Build Map for repeated lookups
- `js-cache-property-access` - Cache object properties in loops
- `js-cache-function-results` - Cache function results in module-level Map
- `js-cache-storage` - Cache localStorage/sessionStorage reads
- `js-combine-iterations` - Combine multiple filter/map into one loop
- `js-length-check-first` - Check array length before expensive comparison
- `js-early-exit` - Return early from functions
- `js-hoist-regexp` - Hoist RegExp creation outside loops
- `js-min-max-loop` - Use loop for min/max instead of sort
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
- `js-tosorted-immutable` - Use toSorted() for immutability
### 8. Advanced Patterns (LOW)
- `advanced-event-handler-refs` - Store event handlers in refs
- `advanced-init-once` - Initialize app once per app load
- `advanced-use-latest` - useLatest for stable callback refs
## How to Use
Read individual rule files for detailed explanations and code examples:
```
rules/async-parallel.md
rules/bundle-barrel-imports.md
```
Each rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`
@@ -0,0 +1,55 @@
---
title: Store Event Handlers in Refs
impact: LOW
impactDescription: stable subscriptions
tags: advanced, hooks, refs, event-handlers, optimization
---
## Store Event Handlers in Refs
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
**Incorrect (re-subscribes on every render):**
```tsx
function useWindowEvent(event: string, handler: (e) => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
```
**Correct (stable subscription):**
```tsx
function useWindowEvent(event: string, handler: (e) => void) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const listener = (e) => handlerRef.current(e)
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event])
}
```
**Alternative: use `useEffectEvent` if you're on latest React:**
```tsx
import { useEffectEvent } from 'react'
function useWindowEvent(event: string, handler: (e) => void) {
const onEvent = useEffectEvent(handler)
useEffect(() => {
window.addEventListener(event, onEvent)
return () => window.removeEventListener(event, onEvent)
}, [event])
}
```
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
@@ -0,0 +1,42 @@
---
title: Initialize App Once, Not Per Mount
impact: LOW-MEDIUM
impactDescription: avoids duplicate init in development
tags: initialization, useEffect, app-startup, side-effects
---
## Initialize App Once, Not Per Mount
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
**Incorrect (runs twice in dev, re-runs on remount):**
```tsx
function Comp() {
useEffect(() => {
loadFromStorage()
checkAuthToken()
}, [])
// ...
}
```
**Correct (once per app load):**
```tsx
let didInit = false
function Comp() {
useEffect(() => {
if (didInit) return
didInit = true
loadFromStorage()
checkAuthToken()
}, [])
// ...
}
```
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
@@ -0,0 +1,39 @@
---
title: useEffectEvent for Stable Callback Refs
impact: LOW
impactDescription: prevents effect re-runs
tags: advanced, hooks, useEffectEvent, refs, optimization
---
## useEffectEvent for Stable Callback Refs
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
**Incorrect (effect re-runs on every callback change):**
```tsx
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
```
**Correct (using React's useEffectEvent):**
```tsx
import { useEffectEvent } from 'react';
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchEvent = useEffectEvent(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchEvent(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
```
@@ -0,0 +1,38 @@
---
title: Prevent Waterfall Chains in API Routes
impact: CRITICAL
impactDescription: 2-10× improvement
tags: api-routes, server-actions, waterfalls, parallelization
---
## Prevent Waterfall Chains in API Routes
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
**Incorrect (config waits for auth, data waits for both):**
```typescript
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
```
**Correct (auth and config start immediately):**
```typescript
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
}
```
For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
@@ -0,0 +1,80 @@
---
title: Defer Await Until Needed
impact: HIGH
impactDescription: avoids blocking unused code paths
tags: async, await, conditional, optimization
---
## Defer Await Until Needed
Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
**Incorrect (blocks both branches):**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
const userData = await fetchUserData(userId)
if (skipProcessing) {
// Returns immediately but still waited for userData
return { skipped: true }
}
// Only this branch uses userData
return processUserData(userData)
}
```
**Correct (only blocks when needed):**
```typescript
async function handleRequest(userId: string, skipProcessing: boolean) {
if (skipProcessing) {
// Returns immediately without waiting
return { skipped: true }
}
// Fetch only when needed
const userData = await fetchUserData(userId)
return processUserData(userData)
}
```
**Another example (early return optimization):**
```typescript
// Incorrect: always fetches permissions
async function updateResource(resourceId: string, userId: string) {
const permissions = await fetchPermissions(userId)
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
// Correct: fetches only when needed
async function updateResource(resourceId: string, userId: string) {
const resource = await getResource(resourceId)
if (!resource) {
return { error: 'Not found' }
}
const permissions = await fetchPermissions(userId)
if (!permissions.canEdit) {
return { error: 'Forbidden' }
}
return await updateResourceData(resource, permissions)
}
```
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
@@ -0,0 +1,51 @@
---
title: Dependency-Based Parallelization
impact: CRITICAL
impactDescription: 2-10× improvement
tags: async, parallelization, dependencies, better-all
---
## Dependency-Based Parallelization
For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
**Incorrect (profile waits for config unnecessarily):**
```typescript
const [user, config] = await Promise.all([
fetchUser(),
fetchConfig()
])
const profile = await fetchProfile(user.id)
```
**Correct (config and profile run in parallel):**
```typescript
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id)
}
})
```
**Alternative without extra dependencies:**
We can also create all the promises first, and do `Promise.all()` at the end.
```typescript
const userPromise = fetchUser()
const profilePromise = userPromise.then(user => fetchProfile(user.id))
const [user, config, profile] = await Promise.all([
userPromise,
fetchConfig(),
profilePromise
])
```
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
@@ -0,0 +1,28 @@
---
title: Promise.all() for Independent Operations
impact: CRITICAL
impactDescription: 2-10× improvement
tags: async, parallelization, promises, waterfalls
---
## Promise.all() for Independent Operations
When async operations have no interdependencies, execute them concurrently using `Promise.all()`.
**Incorrect (sequential execution, 3 round trips):**
```typescript
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()
```
**Correct (parallel execution, 1 round trip):**
```typescript
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
])
```
@@ -0,0 +1,99 @@
---
title: Strategic Suspense Boundaries
impact: HIGH
impactDescription: faster initial paint
tags: async, suspense, streaming, layout-shift
---
## Strategic Suspense Boundaries
Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.
**Incorrect (wrapper blocked by data fetching):**
```tsx
async function Page() {
const data = await fetchData() // Blocks entire page
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<DataDisplay data={data} />
</div>
<div>Footer</div>
</div>
)
}
```
The entire layout waits for data even though only the middle section needs it.
**Correct (wrapper shows immediately, data streams in):**
```tsx
function Page() {
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<div>
<Suspense fallback={<Skeleton />}>
<DataDisplay />
</Suspense>
</div>
<div>Footer</div>
</div>
)
}
async function DataDisplay() {
const data = await fetchData() // Only blocks this component
return <div>{data.content}</div>
}
```
Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data.
**Alternative (share promise across components):**
```tsx
function Page() {
// Start fetch immediately, but don't await
const dataPromise = fetchData()
return (
<div>
<div>Sidebar</div>
<div>Header</div>
<Suspense fallback={<Skeleton />}>
<DataDisplay dataPromise={dataPromise} />
<DataSummary dataPromise={dataPromise} />
</Suspense>
<div>Footer</div>
</div>
)
}
function DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // Unwraps the promise
return <div>{data.content}</div>
}
function DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise) // Reuses the same promise
return <div>{data.summary}</div>
}
```
Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.
**When NOT to use this pattern:**
- Critical data needed for layout decisions (affects positioning)
- SEO-critical content above the fold
- Small, fast queries where suspense overhead isn't worth it
- When you want to avoid layout shift (loading → content jump)
**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.
@@ -0,0 +1,59 @@
---
title: Avoid Barrel File Imports
impact: CRITICAL
impactDescription: 200-800ms import cost, slow builds
tags: bundle, imports, tree-shaking, barrel-files, performance
---
## Avoid Barrel File Imports
Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).
Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.
**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.
**Incorrect (imports entire library):**
```tsx
import { Check, X, Menu } from 'lucide-react'
// Loads 1,583 modules, takes ~2.8s extra in dev
// Runtime cost: 200-800ms on every cold start
import { Button, TextField } from '@mui/material'
// Loads 2,225 modules, takes ~4.2s extra in dev
```
**Correct (imports only what you need):**
```tsx
import Check from 'lucide-react/dist/esm/icons/check'
import X from 'lucide-react/dist/esm/icons/x'
import Menu from 'lucide-react/dist/esm/icons/menu'
// Loads only 3 modules (~2KB vs ~1MB)
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
// Loads only what you use
```
**Alternative (Next.js 13.5+):**
```js
// next.config.js - use optimizePackageImports
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material']
}
}
// Then you can keep the ergonomic barrel imports:
import { Check, X, Menu } from 'lucide-react'
// Automatically transformed to direct imports at build time
```
Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.
Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.
Reference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)
@@ -0,0 +1,31 @@
---
title: Conditional Module Loading
impact: HIGH
impactDescription: loads large data only when needed
tags: bundle, conditional-loading, lazy-loading
---
## Conditional Module Loading
Load large data or modules only when a feature is activated.
**Example (lazy-load animation frames):**
```tsx
function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {
const [frames, setFrames] = useState<Frame[] | null>(null)
useEffect(() => {
if (enabled && !frames && typeof window !== 'undefined') {
import('./animation-frames.js')
.then(mod => setFrames(mod.frames))
.catch(() => setEnabled(false))
}
}, [enabled, frames, setEnabled])
if (!frames) return <Skeleton />
return <Canvas frames={frames} />
}
```
The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.
@@ -0,0 +1,49 @@
---
title: Defer Non-Critical Third-Party Libraries
impact: MEDIUM
impactDescription: loads after hydration
tags: bundle, third-party, analytics, defer
---
## Defer Non-Critical Third-Party Libraries
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
**Incorrect (blocks initial bundle):**
```tsx
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
```
**Correct (loads after hydration):**
```tsx
import dynamic from 'next/dynamic'
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
)
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
```
@@ -0,0 +1,35 @@
---
title: Dynamic Imports for Heavy Components
impact: CRITICAL
impactDescription: directly affects TTI and LCP
tags: bundle, dynamic-import, code-splitting, next-dynamic
---
## Dynamic Imports for Heavy Components
Use `next/dynamic` to lazy-load large components not needed on initial render.
**Incorrect (Monaco bundles with main chunk ~300KB):**
```tsx
import { MonacoEditor } from './monaco-editor'
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
```
**Correct (Monaco loads on demand):**
```tsx
import dynamic from 'next/dynamic'
const MonacoEditor = dynamic(
() => import('./monaco-editor').then(m => m.MonacoEditor),
{ ssr: false }
)
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
```
@@ -0,0 +1,50 @@
---
title: Preload Based on User Intent
impact: MEDIUM
impactDescription: reduces perceived latency
tags: bundle, preload, user-intent, hover
---
## Preload Based on User Intent
Preload heavy bundles before they're needed to reduce perceived latency.
**Example (preload on hover/focus):**
```tsx
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
if (typeof window !== 'undefined') {
void import('./monaco-editor')
}
}
return (
<button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
Open Editor
</button>
)
}
```
**Example (preload when feature flag is enabled):**
```tsx
function FlagsProvider({ children, flags }: Props) {
useEffect(() => {
if (flags.editorEnabled && typeof window !== 'undefined') {
void import('./monaco-editor').then(mod => mod.init())
}
}, [flags.editorEnabled])
return <FlagsContext.Provider value={flags}>
{children}
</FlagsContext.Provider>
}
```
The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.
@@ -0,0 +1,74 @@
---
title: Deduplicate Global Event Listeners
impact: LOW
impactDescription: single listener for N components
tags: client, swr, event-listeners, subscription
---
## Deduplicate Global Event Listeners
Use `useSWRSubscription()` to share global event listeners across component instances.
**Incorrect (N instances = N listeners):**
```tsx
function useKeyboardShortcut(key: string, callback: () => void) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [key, callback])
}
```
When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.
**Correct (N instances = 1 listener):**
```tsx
import useSWRSubscription from 'swr/subscription'
// Module-level Map to track callbacks per key
const keyCallbacks = new Map<string, Set<() => void>>()
function useKeyboardShortcut(key: string, callback: () => void) {
// Register this callback in the Map
useEffect(() => {
if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
}
keyCallbacks.get(key)!.add(callback)
return () => {
const set = keyCallbacks.get(key)
if (set) {
set.delete(callback)
if (set.size === 0) {
keyCallbacks.delete(key)
}
}
}
}, [key, callback])
useSWRSubscription('global-keydown', () => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
}
function Profile() {
// Multiple shortcuts will share the same listener
useKeyboardShortcut('p', () => { /* ... */ })
useKeyboardShortcut('k', () => { /* ... */ })
// ...
}
```
@@ -0,0 +1,71 @@
---
title: Version and Minimize localStorage Data
impact: MEDIUM
impactDescription: prevents schema conflicts, reduces storage size
tags: client, localStorage, storage, versioning, data-minimization
---
## Version and Minimize localStorage Data
Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.
**Incorrect:**
```typescript
// No version, stores everything, no error handling
localStorage.setItem('userConfig', JSON.stringify(fullUserObject))
const data = localStorage.getItem('userConfig')
```
**Correct:**
```typescript
const VERSION = 'v2'
function saveConfig(config: { theme: string; language: string }) {
try {
localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))
} catch {
// Throws in incognito/private browsing, quota exceeded, or disabled
}
}
function loadConfig() {
try {
const data = localStorage.getItem(`userConfig:${VERSION}`)
return data ? JSON.parse(data) : null
} catch {
return null
}
}
// Migration from v1 to v2
function migrate() {
try {
const v1 = localStorage.getItem('userConfig:v1')
if (v1) {
const old = JSON.parse(v1)
saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })
localStorage.removeItem('userConfig:v1')
}
} catch {}
}
```
**Store minimal fields from server responses:**
```typescript
// User object has 20+ fields, only store what UI needs
function cachePrefs(user: FullUser) {
try {
localStorage.setItem('prefs:v1', JSON.stringify({
theme: user.preferences.theme,
notifications: user.preferences.notifications
}))
} catch {}
}
```
**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.
**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.
@@ -0,0 +1,48 @@
---
title: Use Passive Event Listeners for Scrolling Performance
impact: MEDIUM
impactDescription: eliminates scroll delay caused by event listeners
tags: client, event-listeners, scrolling, performance, touch, wheel
---
## Use Passive Event Listeners for Scrolling Performance
Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.
**Incorrect:**
```typescript
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
document.addEventListener('touchstart', handleTouch)
document.addEventListener('wheel', handleWheel)
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
```
**Correct:**
```typescript
useEffect(() => {
const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)
const handleWheel = (e: WheelEvent) => console.log(e.deltaY)
document.addEventListener('touchstart', handleTouch, { passive: true })
document.addEventListener('wheel', handleWheel, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouch)
document.removeEventListener('wheel', handleWheel)
}
}, [])
```
**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.
**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.
@@ -0,0 +1,56 @@
---
title: Use SWR for Automatic Deduplication
impact: MEDIUM-HIGH
impactDescription: automatic deduplication
tags: client, swr, deduplication, data-fetching
---
## Use SWR for Automatic Deduplication
SWR enables request deduplication, caching, and revalidation across component instances.
**Incorrect (no deduplication, each instance fetches):**
```tsx
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
}
```
**Correct (multiple instances share one request):**
```tsx
import useSWR from 'swr'
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
}
```
**For immutable data:**
```tsx
import { useImmutableSWR } from '@/lib/swr'
function StaticContent() {
const { data } = useImmutableSWR('/api/config', fetcher)
}
```
**For mutations:**
```tsx
import { useSWRMutation } from 'swr/mutation'
function UpdateButton() {
const { trigger } = useSWRMutation('/api/user', updateUser)
return <button onClick={() => trigger()}>Update</button>
}
```
Reference: [https://swr.vercel.app](https://swr.vercel.app)
@@ -0,0 +1,107 @@
---
title: Avoid Layout Thrashing
impact: MEDIUM
impactDescription: prevents forced synchronous layouts and reduces performance bottlenecks
tags: javascript, dom, css, performance, reflow, layout-thrashing
---
## Avoid Layout Thrashing
Avoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.
**This is OK (browser batches style changes):**
```typescript
function updateElementStyles(element: HTMLElement) {
// Each line invalidates style, but browser batches the recalculation
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
}
```
**Incorrect (interleaved reads and writes force reflows):**
```typescript
function layoutThrashing(element: HTMLElement) {
element.style.width = '100px'
const width = element.offsetWidth // Forces reflow
element.style.height = '200px'
const height = element.offsetHeight // Forces another reflow
}
```
**Correct (batch writes, then read once):**
```typescript
function updateElementStyles(element: HTMLElement) {
// Batch all writes together
element.style.width = '100px'
element.style.height = '200px'
element.style.backgroundColor = 'blue'
element.style.border = '1px solid black'
// Read after all writes are done (single reflow)
const { width, height } = element.getBoundingClientRect()
}
```
**Correct (batch reads, then writes):**
```typescript
function avoidThrashing(element: HTMLElement) {
// Read phase - all layout queries first
const rect1 = element.getBoundingClientRect()
const offsetWidth = element.offsetWidth
const offsetHeight = element.offsetHeight
// Write phase - all style changes after
element.style.width = '100px'
element.style.height = '200px'
}
```
**Better: use CSS classes**
```css
.highlighted-box {
width: 100px;
height: 200px;
background-color: blue;
border: 1px solid black;
}
```
```typescript
function updateElementStyles(element: HTMLElement) {
element.classList.add('highlighted-box')
const { width, height } = element.getBoundingClientRect()
}
```
**React example:**
```tsx
// Incorrect: interleaving style changes with layout queries
function Box({ isHighlighted }: { isHighlighted: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current && isHighlighted) {
ref.current.style.width = '100px'
const width = ref.current.offsetWidth // Forces layout
ref.current.style.height = '200px'
}
}, [isHighlighted])
return <div ref={ref}>Content</div>
}
// Correct: toggle class
function Box({ isHighlighted }: { isHighlighted: boolean }) {
return (
<div className={isHighlighted ? 'highlighted-box' : ''}>
Content
</div>
)
}
```
Prefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.
See [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.
@@ -0,0 +1,80 @@
---
title: Cache Repeated Function Calls
impact: MEDIUM
impactDescription: avoid redundant computation
tags: javascript, cache, memoization, performance
---
## Cache Repeated Function Calls
Use a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.
**Incorrect (redundant computation):**
```typescript
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// slugify() called 100+ times for same project names
const slug = slugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
```
**Correct (cached results):**
```typescript
// Module-level cache
const slugifyCache = new Map<string, string>()
function cachedSlugify(text: string): string {
if (slugifyCache.has(text)) {
return slugifyCache.get(text)!
}
const result = slugify(text)
slugifyCache.set(text, result)
return result
}
function ProjectList({ projects }: { projects: Project[] }) {
return (
<div>
{projects.map(project => {
// Computed only once per unique project name
const slug = cachedSlugify(project.name)
return <ProjectCard key={project.id} slug={slug} />
})}
</div>
)
}
```
**Simpler pattern for single-value functions:**
```typescript
let isLoggedInCache: boolean | null = null
function isLoggedIn(): boolean {
if (isLoggedInCache !== null) {
return isLoggedInCache
}
isLoggedInCache = document.cookie.includes('auth=')
return isLoggedInCache
}
// Clear cache when auth changes
function onAuthChange() {
isLoggedInCache = null
}
```
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
Reference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)
@@ -0,0 +1,28 @@
---
title: Cache Property Access in Loops
impact: LOW-MEDIUM
impactDescription: reduces lookups
tags: javascript, loops, optimization, caching
---
## Cache Property Access in Loops
Cache object property lookups in hot paths.
**Incorrect (3 lookups × N iterations):**
```typescript
for (let i = 0; i < arr.length; i++) {
process(obj.config.settings.value)
}
```
**Correct (1 lookup total):**
```typescript
const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
process(value)
}
```
@@ -0,0 +1,70 @@
---
title: Cache Storage API Calls
impact: LOW-MEDIUM
impactDescription: reduces expensive I/O
tags: javascript, localStorage, storage, caching, performance
---
## Cache Storage API Calls
`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.
**Incorrect (reads storage on every call):**
```typescript
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
}
// Called 10 times = 10 storage reads
```
**Correct (Map cache):**
```typescript
const storageCache = new Map<string, string | null>()
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
}
return storageCache.get(key)
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // keep cache in sync
}
```
Use a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.
**Cookie caching:**
```typescript
let cookieCache: Record<string, string> | null = null
function getCookie(name: string) {
if (!cookieCache) {
cookieCache = Object.fromEntries(
document.cookie.split('; ').map(c => c.split('='))
)
}
return cookieCache[name]
}
```
**Important (invalidate on external changes):**
If storage can change externally (another tab, server-set cookies), invalidate cache:
```typescript
window.addEventListener('storage', (e) => {
if (e.key) storageCache.delete(e.key)
})
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
storageCache.clear()
}
})
```
@@ -0,0 +1,32 @@
---
title: Combine Multiple Array Iterations
impact: LOW-MEDIUM
impactDescription: reduces iterations
tags: javascript, arrays, loops, performance
---
## Combine Multiple Array Iterations
Multiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.
**Incorrect (3 iterations):**
```typescript
const admins = users.filter(u => u.isAdmin)
const testers = users.filter(u => u.isTester)
const inactive = users.filter(u => !u.isActive)
```
**Correct (1 iteration):**
```typescript
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
}
```
@@ -0,0 +1,50 @@
---
title: Early Return from Functions
impact: LOW-MEDIUM
impactDescription: avoids unnecessary computation
tags: javascript, functions, optimization, early-return
---
## Early Return from Functions
Return early when result is determined to skip unnecessary processing.
**Incorrect (processes all items even after finding answer):**
```typescript
function validateUsers(users: User[]) {
let hasError = false
let errorMessage = ''
for (const user of users) {
if (!user.email) {
hasError = true
errorMessage = 'Email required'
}
if (!user.name) {
hasError = true
errorMessage = 'Name required'
}
// Continues checking all users even after error found
}
return hasError ? { valid: false, error: errorMessage } : { valid: true }
}
```
**Correct (returns immediately on first error):**
```typescript
function validateUsers(users: User[]) {
for (const user of users) {
if (!user.email) {
return { valid: false, error: 'Email required' }
}
if (!user.name) {
return { valid: false, error: 'Name required' }
}
}
return { valid: true }
}
```
@@ -0,0 +1,45 @@
---
title: Hoist RegExp Creation
impact: LOW-MEDIUM
impactDescription: avoids recreation
tags: javascript, regexp, optimization, memoization
---
## Hoist RegExp Creation
Don't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.
**Incorrect (new RegExp every render):**
```tsx
function Highlighter({ text, query }: Props) {
const regex = new RegExp(`(${query})`, 'gi')
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
```
**Correct (memoize or hoist):**
```tsx
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function Highlighter({ text, query }: Props) {
const regex = useMemo(
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
[query]
)
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
```
**Warning (global regex has mutable state):**
Global regex (`/g`) has mutable `lastIndex` state:
```typescript
const regex = /foo/g
regex.test('foo') // true, lastIndex = 3
regex.test('foo') // false, lastIndex = 0
```
@@ -0,0 +1,37 @@
---
title: Build Index Maps for Repeated Lookups
impact: LOW-MEDIUM
impactDescription: 1M ops to 2K ops
tags: javascript, map, indexing, optimization, performance
---
## Build Index Maps for Repeated Lookups
Multiple `.find()` calls by the same key should use a Map.
**Incorrect (O(n) per lookup):**
```typescript
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
...order,
user: users.find(u => u.id === order.userId)
}))
}
```
**Correct (O(1) per lookup):**
```typescript
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
return orders.map(order => ({
...order,
user: userById.get(order.userId)
}))
}
```
Build map once (O(n)), then all lookups are O(1).
For 1000 orders × 1000 users: 1M ops → 2K ops.
@@ -0,0 +1,49 @@
---
title: Early Length Check for Array Comparisons
impact: MEDIUM-HIGH
impactDescription: avoids expensive operations when lengths differ
tags: javascript, arrays, performance, optimization, comparison
---
## Early Length Check for Array Comparisons
When comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.
In real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).
**Incorrect (always runs expensive comparison):**
```typescript
function hasChanges(current: string[], original: string[]) {
// Always sorts and joins, even when lengths differ
return current.sort().join() !== original.sort().join()
}
```
Two O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.
**Correct (O(1) length check first):**
```typescript
function hasChanges(current: string[], original: string[]) {
// Early return if lengths differ
if (current.length !== original.length) {
return true
}
// Only sort when lengths match
const currentSorted = current.toSorted()
const originalSorted = original.toSorted()
for (let i = 0; i < currentSorted.length; i++) {
if (currentSorted[i] !== originalSorted[i]) {
return true
}
}
return false
}
```
This new approach is more efficient because:
- It avoids the overhead of sorting and joining the arrays when lengths differ
- It avoids consuming memory for the joined strings (especially important for large arrays)
- It avoids mutating the original arrays
- It returns early when a difference is found
@@ -0,0 +1,82 @@
---
title: Use Loop for Min/Max Instead of Sort
impact: LOW
impactDescription: O(n) instead of O(n log n)
tags: javascript, arrays, performance, sorting, algorithms
---
## Use Loop for Min/Max Instead of Sort
Finding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.
**Incorrect (O(n log n) - sort to find latest):**
```typescript
interface Project {
id: string
name: string
updatedAt: number
}
function getLatestProject(projects: Project[]) {
const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)
return sorted[0]
}
```
Sorts the entire array just to find the maximum value.
**Incorrect (O(n log n) - sort for oldest and newest):**
```typescript
function getOldestAndNewest(projects: Project[]) {
const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)
return { oldest: sorted[0], newest: sorted[sorted.length - 1] }
}
```
Still sorts unnecessarily when only min/max are needed.
**Correct (O(n) - single loop):**
```typescript
function getLatestProject(projects: Project[]) {
if (projects.length === 0) return null
let latest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt > latest.updatedAt) {
latest = projects[i]
}
}
return latest
}
function getOldestAndNewest(projects: Project[]) {
if (projects.length === 0) return { oldest: null, newest: null }
let oldest = projects[0]
let newest = projects[0]
for (let i = 1; i < projects.length; i++) {
if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]
if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]
}
return { oldest, newest }
}
```
Single pass through the array, no copying, no sorting.
**Alternative (Math.min/Math.max for small arrays):**
```typescript
const numbers = [5, 2, 8, 1, 9]
const min = Math.min(...numbers)
const max = Math.max(...numbers)
```
This works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.
@@ -0,0 +1,24 @@
---
title: Use Set/Map for O(1) Lookups
impact: LOW-MEDIUM
impactDescription: O(n) to O(1)
tags: javascript, set, map, data-structures, performance
---
## Use Set/Map for O(1) Lookups
Convert arrays to Set/Map for repeated membership checks.
**Incorrect (O(n) per check):**
```typescript
const allowedIds = ['a', 'b', 'c', ...]
items.filter(item => allowedIds.includes(item.id))
```
**Correct (O(1) per check):**
```typescript
const allowedIds = new Set(['a', 'b', 'c', ...])
items.filter(item => allowedIds.has(item.id))
```
@@ -0,0 +1,57 @@
---
title: Use toSorted() Instead of sort() for Immutability
impact: MEDIUM-HIGH
impactDescription: prevents mutation bugs in React state
tags: javascript, arrays, immutability, react, state, mutation
---
## Use toSorted() Instead of sort() for Immutability
`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.
**Incorrect (mutates original array):**
```typescript
function UserList({ users }: { users: User[] }) {
// Mutates the users prop array!
const sorted = useMemo(
() => users.sort((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
```
**Correct (creates new array):**
```typescript
function UserList({ users }: { users: User[] }) {
// Creates new sorted array, original unchanged
const sorted = useMemo(
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
[users]
)
return <div>{sorted.map(renderUser)}</div>
}
```
**Why this matters in React:**
1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only
2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior
**Browser support (fallback for older browsers):**
`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:
```typescript
// Fallback for older browsers
const sorted = [...items].sort((a, b) => a.value - b.value)
```
**Other immutable array methods:**
- `.toSorted()` - immutable sort
- `.toReversed()` - immutable reverse
- `.toSpliced()` - immutable splice
- `.with()` - immutable element replacement
@@ -0,0 +1,26 @@
---
title: Use Activity Component for Show/Hide
impact: MEDIUM
impactDescription: preserves state/DOM
tags: rendering, activity, visibility, state-preservation
---
## Use Activity Component for Show/Hide
Use React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.
**Usage:**
```tsx
import { Activity } from 'react'
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
}
```
Avoids expensive re-renders and state loss.
@@ -0,0 +1,47 @@
---
title: Animate SVG Wrapper Instead of SVG Element
impact: LOW
impactDescription: enables hardware acceleration
tags: rendering, svg, css, animation, performance
---
## Animate SVG Wrapper Instead of SVG Element
Many browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.
**Incorrect (animating SVG directly - no hardware acceleration):**
```tsx
function LoadingSpinner() {
return (
<svg
className="animate-spin"
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
)
}
```
**Correct (animating wrapper div - hardware accelerated):**
```tsx
function LoadingSpinner() {
return (
<div className="animate-spin">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" stroke="currentColor" />
</svg>
</div>
)
}
```
This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
@@ -0,0 +1,40 @@
---
title: Use Explicit Conditional Rendering
impact: LOW
impactDescription: prevents rendering 0 or NaN
tags: rendering, conditional, jsx, falsy-values
---
## Use Explicit Conditional Rendering
Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
**Incorrect (renders "0" when count is 0):**
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count && <span className="badge">{count}</span>}
</div>
)
}
// When count = 0, renders: <div>0</div>
// When count = 5, renders: <div><span class="badge">5</span></div>
```
**Correct (renders nothing when count is 0):**
```tsx
function Badge({ count }: { count: number }) {
return (
<div>
{count > 0 ? <span className="badge">{count}</span> : null}
</div>
)
}
// When count = 0, renders: <div></div>
// When count = 5, renders: <div><span class="badge">5</span></div>
```
@@ -0,0 +1,38 @@
---
title: CSS content-visibility for Long Lists
impact: HIGH
impactDescription: faster initial render
tags: rendering, css, content-visibility, long-lists
---
## CSS content-visibility for Long Lists
Apply `content-visibility: auto` to defer off-screen rendering.
**CSS:**
```css
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
```
**Example:**
```tsx
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
)
}
```
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
@@ -0,0 +1,46 @@
---
title: Hoist Static JSX Elements
impact: LOW
impactDescription: avoids re-creation
tags: rendering, jsx, static, optimization
---
## Hoist Static JSX Elements
Extract static JSX outside components to avoid re-creation.
**Incorrect (recreates element every render):**
```tsx
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
}
function Container() {
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
}
```
**Correct (reuses same element):**
```tsx
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
function Container() {
return (
<div>
{loading && loadingSkeleton}
</div>
)
}
```
This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.
@@ -0,0 +1,82 @@
---
title: Prevent Hydration Mismatch Without Flickering
impact: MEDIUM
impactDescription: avoids visual flicker and hydration errors
tags: rendering, ssr, hydration, localStorage, flicker
---
## Prevent Hydration Mismatch Without Flickering
When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
**Incorrect (breaks SSR):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
// localStorage is not available on server - throws error
const theme = localStorage.getItem('theme') || 'light'
return (
<div className={theme}>
{children}
</div>
)
}
```
Server-side rendering will fail because `localStorage` is undefined.
**Incorrect (visual flickering):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light')
useEffect(() => {
// Runs after hydration - causes visible flash
const stored = localStorage.getItem('theme')
if (stored) {
setTheme(stored)
}
}, [])
return (
<div className={theme}>
{children}
</div>
)
}
```
Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
**Correct (no flicker, no hydration mismatch):**
```tsx
function ThemeWrapper({ children }: { children: ReactNode }) {
return (
<>
<div id="theme-wrapper">
{children}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme') || 'light';
var el = document.getElementById('theme-wrapper');
if (el) el.className = theme;
} catch (e) {}
})();
`,
}}
/>
</>
)
}
```
The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
@@ -0,0 +1,30 @@
---
title: Suppress Expected Hydration Mismatches
impact: LOW-MEDIUM
impactDescription: avoids noisy hydration warnings for known differences
tags: rendering, hydration, ssr, nextjs
---
## Suppress Expected Hydration Mismatches
In SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Dont overuse it.
**Incorrect (known mismatch warnings):**
```tsx
function Timestamp() {
return <span>{new Date().toLocaleString()}</span>
}
```
**Correct (suppress expected mismatch only):**
```tsx
function Timestamp() {
return (
<span suppressHydrationWarning>
{new Date().toLocaleString()}
</span>
)
}
```
@@ -0,0 +1,28 @@
---
title: Optimize SVG Precision
impact: LOW
impactDescription: reduces file size
tags: rendering, svg, optimization, svgo
---
## Optimize SVG Precision
Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
**Incorrect (excessive precision):**
```svg
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
```
**Correct (1 decimal place):**
```svg
<path d="M 10.3 20.8 L 30.9 40.2" />
```
**Automate with SVGO:**
```bash
npx svgo --precision=1 --multipass icon.svg
```
@@ -0,0 +1,75 @@
---
title: Use useTransition Over Manual Loading States
impact: LOW
impactDescription: reduces re-renders and improves code clarity
tags: rendering, transitions, useTransition, loading, state
---
## Use useTransition Over Manual Loading States
Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.
**Incorrect (manual loading state):**
```tsx
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
const handleSearch = async (value: string) => {
setIsLoading(true)
setQuery(value)
const data = await fetchResults(value)
setResults(data)
setIsLoading(false)
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isLoading && <Spinner />}
<ResultsList results={results} />
</>
)
}
```
**Correct (useTransition with built-in pending state):**
```tsx
import { useTransition, useState } from 'react'
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleSearch = (value: string) => {
setQuery(value) // Update input immediately
startTransition(async () => {
// Fetch and update results
const data = await fetchResults(value)
setResults(data)
})
}
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
```
**Benefits:**
- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`
- **Error resilience**: Pending state correctly resets even if the transition throws
- **Better responsiveness**: Keeps the UI responsive during updates
- **Interrupt handling**: New transitions automatically cancel pending ones
Reference: [useTransition](https://react.dev/reference/react/useTransition)
@@ -0,0 +1,39 @@
---
title: Defer State Reads to Usage Point
impact: MEDIUM
impactDescription: avoids unnecessary subscriptions
tags: rerender, searchParams, localStorage, optimization
---
## Defer State Reads to Usage Point
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
**Incorrect (subscribes to all searchParams changes):**
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
```
**Correct (reads on demand, no subscription):**
```tsx
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
```
@@ -0,0 +1,45 @@
---
title: Narrow Effect Dependencies
impact: LOW
impactDescription: minimizes effect re-runs
tags: rerender, useEffect, dependencies, optimization
---
## Narrow Effect Dependencies
Specify primitive dependencies instead of objects to minimize effect re-runs.
**Incorrect (re-runs on any user field change):**
```tsx
useEffect(() => {
console.log(user.id)
}, [user])
```
**Correct (re-runs only when id changes):**
```tsx
useEffect(() => {
console.log(user.id)
}, [user.id])
```
**For derived state, compute outside effect:**
```tsx
// Incorrect: runs on width=767, 766, 765...
useEffect(() => {
if (width < 768) {
enableMobileMode()
}
}, [width])
// Correct: runs only on boolean transition
const isMobile = width < 768
useEffect(() => {
if (isMobile) {
enableMobileMode()
}
}, [isMobile])
```
@@ -0,0 +1,40 @@
---
title: Calculate Derived State During Rendering
impact: MEDIUM
impactDescription: avoids redundant renders and state drift
tags: rerender, derived-state, useEffect, state
---
## Calculate Derived State During Rendering
If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.
**Incorrect (redundant state and effect):**
```tsx
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
return <p>{fullName}</p>
}
```
**Correct (derive during render):**
```tsx
function Form() {
const [firstName, setFirstName] = useState('First')
const [lastName, setLastName] = useState('Last')
const fullName = firstName + ' ' + lastName
return <p>{fullName}</p>
}
```
References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)
@@ -0,0 +1,29 @@
---
title: Subscribe to Derived State
impact: MEDIUM
impactDescription: reduces re-render frequency
tags: rerender, derived-state, media-query, optimization
---
## Subscribe to Derived State
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
**Incorrect (re-renders on every pixel change):**
```tsx
function Sidebar() {
const width = useWindowWidth() // updates continuously
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
```
**Correct (re-renders only when boolean changes):**
```tsx
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'} />
}
```
@@ -0,0 +1,74 @@
---
title: Use Functional setState Updates
impact: MEDIUM
impactDescription: prevents stale closures and unnecessary callback recreations
tags: react, hooks, useState, useCallback, callbacks, closures
---
## Use Functional setState Updates
When updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.
**Incorrect (requires state as dependency):**
```tsx
function TodoList() {
const [items, setItems] = useState(initialItems)
// Callback must depend on items, recreated on every items change
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems])
}, [items]) // ❌ items dependency causes recreations
// Risk of stale closure if dependency is forgotten
const removeItem = useCallback((id: string) => {
setItems(items.filter(item => item.id !== id))
}, []) // ❌ Missing items dependency - will use stale items!
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
```
The first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.
**Correct (stable callbacks, no stale closures):**
```tsx
function TodoList() {
const [items, setItems] = useState(initialItems)
// Stable callback, never recreated
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems])
}, []) // ✅ No dependencies needed
// Always uses latest state, no stale closure risk
const removeItem = useCallback((id: string) => {
setItems(curr => curr.filter(item => item.id !== id))
}, []) // ✅ Safe and stable
return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />
}
```
**Benefits:**
1. **Stable callback references** - Callbacks don't need to be recreated when state changes
2. **No stale closures** - Always operates on the latest state value
3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks
4. **Prevents bugs** - Eliminates the most common source of React closure bugs
**When to use functional updates:**
- Any setState that depends on the current state value
- Inside useCallback/useMemo when state is needed
- Event handlers that reference state
- Async operations that update state
**When direct updates are fine:**
- Setting state to a static value: `setCount(0)`
- Setting state from props/arguments only: `setName(newName)`
- State doesn't depend on previous value
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.
@@ -0,0 +1,58 @@
---
title: Use Lazy State Initialization
impact: MEDIUM
impactDescription: wasted computation on every render
tags: react, hooks, useState, performance, initialization
---
## Use Lazy State Initialization
Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
**Incorrect (runs on every render):**
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs on EVERY render, even after initialization
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
const [query, setQuery] = useState('')
// When query changes, buildSearchIndex runs again unnecessarily
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs on every render
const [settings, setSettings] = useState(
JSON.parse(localStorage.getItem('settings') || '{}')
)
return <SettingsForm settings={settings} onChange={setSettings} />
}
```
**Correct (runs only once):**
```tsx
function FilteredList({ items }: { items: Item[] }) {
// buildSearchIndex() runs ONLY on initial render
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
const [query, setQuery] = useState('')
return <SearchResults index={searchIndex} query={query} />
}
function UserProfile() {
// JSON.parse runs only on initial render
const [settings, setSettings] = useState(() => {
const stored = localStorage.getItem('settings')
return stored ? JSON.parse(stored) : {}
})
return <SettingsForm settings={settings} onChange={setSettings} />
}
```
Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
@@ -0,0 +1,38 @@
---
title: Extract Default Non-primitive Parameter Value from Memoized Component to Constant
impact: MEDIUM
impactDescription: restores memoization by using a constant for default value
tags: rerender, memo, optimization
---
## Extract Default Non-primitive Parameter Value from Memoized Component to Constant
When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.
To address this issue, extract the default value into a constant.
**Incorrect (`onClick` has different values on every rerender):**
```tsx
const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {
// ...
})
// Used without optional onClick
<UserAvatar />
```
**Correct (stable default value):**
```tsx
const NOOP = () => {};
const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {
// ...
})
// Used without optional onClick
<UserAvatar />
```
@@ -0,0 +1,44 @@
---
title: Extract to Memoized Components
impact: MEDIUM
impactDescription: enables early returns
tags: rerender, memo, useMemo, optimization
---
## Extract to Memoized Components
Extract expensive work into memoized components to enable early returns before computation.
**Incorrect (computes avatar even when loading):**
```tsx
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
```
**Correct (skips computation when loading):**
```tsx
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
```
**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.
@@ -0,0 +1,45 @@
---
title: Put Interaction Logic in Event Handlers
impact: MEDIUM
impactDescription: avoids effect re-runs and duplicate side effects
tags: rerender, useEffect, events, side-effects, dependencies
---
## Put Interaction Logic in Event Handlers
If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.
**Incorrect (event modeled as state + effect):**
```tsx
function Form() {
const [submitted, setSubmitted] = useState(false)
const theme = useContext(ThemeContext)
useEffect(() => {
if (submitted) {
post('/api/register')
showToast('Registered', theme)
}
}, [submitted, theme])
return <button onClick={() => setSubmitted(true)}>Submit</button>
}
```
**Correct (do it in the handler):**
```tsx
function Form() {
const theme = useContext(ThemeContext)
function handleSubmit() {
post('/api/register')
showToast('Registered', theme)
}
return <button onClick={handleSubmit}>Submit</button>
}
```
Reference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)
@@ -0,0 +1,35 @@
---
title: Do not wrap a simple expression with a primitive result type in useMemo
impact: LOW-MEDIUM
impactDescription: wasted computation on every render
tags: rerender, useMemo, optimization
---
## Do not wrap a simple expression with a primitive result type in useMemo
When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.
Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.
**Incorrect:**
```tsx
function Header({ user, notifications }: Props) {
const isLoading = useMemo(() => {
return user.isLoading || notifications.isLoading
}, [user.isLoading, notifications.isLoading])
if (isLoading) return <Skeleton />
// return some markup
}
```
**Correct:**
```tsx
function Header({ user, notifications }: Props) {
const isLoading = user.isLoading || notifications.isLoading
if (isLoading) return <Skeleton />
// return some markup
}
```
@@ -0,0 +1,40 @@
---
title: Use Transitions for Non-Urgent Updates
impact: MEDIUM
impactDescription: maintains UI responsiveness
tags: rerender, transitions, startTransition, performance
---
## Use Transitions for Non-Urgent Updates
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
**Incorrect (blocks UI on every scroll):**
```tsx
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
```
**Correct (non-blocking updates):**
```tsx
import { startTransition } from 'react'
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
```
@@ -0,0 +1,73 @@
---
title: Use useRef for Transient Values
impact: MEDIUM
impactDescription: avoids unnecessary re-renders on frequent updates
tags: rerender, useref, state, performance
---
## Use useRef for Transient Values
When a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.
**Incorrect (renders every update):**
```tsx
function Tracker() {
const [lastX, setLastX] = useState(0)
useEffect(() => {
const onMove = (e: MouseEvent) => setLastX(e.clientX)
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
return (
<div
style={{
position: 'fixed',
top: 0,
left: lastX,
width: 8,
height: 8,
background: 'black',
}}
/>
)
}
```
**Correct (no re-render for tracking):**
```tsx
function Tracker() {
const lastXRef = useRef(0)
const dotRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const onMove = (e: MouseEvent) => {
lastXRef.current = e.clientX
const node = dotRef.current
if (node) {
node.style.transform = `translateX(${e.clientX}px)`
}
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
return (
<div
ref={dotRef}
style={{
position: 'fixed',
top: 0,
left: 0,
width: 8,
height: 8,
background: 'black',
transform: 'translateX(0px)',
}}
/>
)
}
```
@@ -0,0 +1,73 @@
---
title: Use after() for Non-Blocking Operations
impact: MEDIUM
impactDescription: faster response times
tags: server, async, logging, analytics, side-effects
---
## Use after() for Non-Blocking Operations
Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.
**Incorrect (blocks response):**
```tsx
import { logUserAction } from '@/app/utils'
export async function POST(request: Request) {
// Perform mutation
await updateDatabase(request)
// Logging blocks the response
const userAgent = request.headers.get('user-agent') || 'unknown'
await logUserAction({ userAgent })
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
```
**Correct (non-blocking):**
```tsx
import { after } from 'next/server'
import { headers, cookies } from 'next/headers'
import { logUserAction } from '@/app/utils'
export async function POST(request: Request) {
// Perform mutation
await updateDatabase(request)
// Log after response is sent
after(async () => {
const userAgent = (await headers()).get('user-agent') || 'unknown'
const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
logUserAction({ sessionCookie, userAgent })
})
return new Response(JSON.stringify({ status: 'success' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
}
```
The response is sent immediately while logging happens in the background.
**Common use cases:**
- Analytics tracking
- Audit logging
- Sending notifications
- Cache invalidation
- Cleanup tasks
**Important notes:**
- `after()` runs even if the response fails or redirects
- Works in Server Actions, Route Handlers, and Server Components
Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)
@@ -0,0 +1,96 @@
---
title: Authenticate Server Actions Like API Routes
impact: CRITICAL
impactDescription: prevents unauthorized access to server mutations
tags: server, server-actions, authentication, security, authorization
---
## Authenticate Server Actions Like API Routes
**Impact: CRITICAL (prevents unauthorized access to server mutations)**
Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.
Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation."
**Incorrect (no authentication check):**
```typescript
'use server'
export async function deleteUser(userId: string) {
// Anyone can call this! No auth check
await db.user.delete({ where: { id: userId } })
return { success: true }
}
```
**Correct (authentication inside the action):**
```typescript
'use server'
import { verifySession } from '@/lib/auth'
import { unauthorized } from '@/lib/errors'
export async function deleteUser(userId: string) {
// Always check auth inside the action
const session = await verifySession()
if (!session) {
throw unauthorized('Must be logged in')
}
// Check authorization too
if (session.user.role !== 'admin' && session.user.id !== userId) {
throw unauthorized('Cannot delete other users')
}
await db.user.delete({ where: { id: userId } })
return { success: true }
}
```
**With input validation:**
```typescript
'use server'
import { verifySession } from '@/lib/auth'
import { z } from 'zod'
const updateProfileSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email()
})
export async function updateProfile(data: unknown) {
// Validate input first
const validated = updateProfileSchema.parse(data)
// Then authenticate
const session = await verifySession()
if (!session) {
throw new Error('Unauthorized')
}
// Then authorize
if (session.user.id !== validated.userId) {
throw new Error('Can only update own profile')
}
// Finally perform the mutation
await db.user.update({
where: { id: validated.userId },
data: {
name: validated.name,
email: validated.email
}
})
return { success: true }
}
```
Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)
@@ -0,0 +1,41 @@
---
title: Cross-Request LRU Caching
impact: HIGH
impactDescription: caches across requests
tags: server, cache, lru, cross-request
---
## Cross-Request LRU Caching
`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
**Implementation:**
```typescript
import { LRUCache } from 'lru-cache'
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 minutes
})
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
}
// Request 1: DB query, result cached
// Request 2: cache hit, no DB query
```
Use when sequential user actions hit multiple endpoints needing the same data within seconds.
**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.
**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.
Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
@@ -0,0 +1,76 @@
---
title: Per-Request Deduplication with React.cache()
impact: MEDIUM
impactDescription: deduplicates within request
tags: server, cache, react-cache, deduplication
---
## Per-Request Deduplication with React.cache()
Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
**Usage:**
```typescript
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user?.id) return null
return await db.user.findUnique({
where: { id: session.user.id }
})
})
```
Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
**Avoid inline objects as arguments:**
`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.
**Incorrect (always cache miss):**
```typescript
const getUser = cache(async (params: { uid: number }) => {
return await db.user.findUnique({ where: { id: params.uid } })
})
// Each call creates new object, never hits cache
getUser({ uid: 1 })
getUser({ uid: 1 }) // Cache miss, runs query again
```
**Correct (cache hit):**
```typescript
const getUser = cache(async (uid: number) => {
return await db.user.findUnique({ where: { id: uid } })
})
// Primitive args use value equality
getUser(1)
getUser(1) // Cache hit, returns cached result
```
If you must pass objects, pass the same reference:
```typescript
const params = { uid: 1 }
getUser(params) // Query runs
getUser(params) // Cache hit (same reference)
```
**Next.js-Specific Note:**
In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:
- Database queries (Prisma, Drizzle, etc.)
- Heavy computations
- Authentication checks
- File system operations
- Any non-fetch async work
Use `React.cache()` to deduplicate these operations across your component tree.
Reference: [React.cache documentation](https://react.dev/reference/react/cache)
@@ -0,0 +1,65 @@
---
title: Avoid Duplicate Serialization in RSC Props
impact: LOW
impactDescription: reduces network payload by avoiding duplicate serialization
tags: server, rsc, serialization, props, client-components
---
## Avoid Duplicate Serialization in RSC Props
**Impact: LOW (reduces network payload by avoiding duplicate serialization)**
RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.
**Incorrect (duplicates array):**
```tsx
// RSC: sends 6 strings (2 arrays × 3 items)
<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />
```
**Correct (sends 3 strings):**
```tsx
// RSC: send once
<ClientList usernames={usernames} />
// Client: transform there
'use client'
const sorted = useMemo(() => [...usernames].sort(), [usernames])
```
**Nested deduplication behavior:**
Deduplication works recursively. Impact varies by data type:
- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated
- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference
```tsx
// string[] - duplicates everything
usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings
// object[] - duplicates array structure only
users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)
```
**Operations breaking deduplication (create new references):**
- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`
- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`
**More examples:**
```tsx
// ❌ Bad
<C users={users} active={users.filter(u => u.active)} />
<C product={product} productName={product.name} />
// ✅ Good
<C users={users} />
<C product={product} />
// Do filtering/destructuring in client
```
**Exception:** Pass derived data when transformation is expensive or client doesn't need original.
@@ -0,0 +1,83 @@
---
title: Parallel Data Fetching with Component Composition
impact: CRITICAL
impactDescription: eliminates server-side waterfalls
tags: server, rsc, parallel-fetching, composition
---
## Parallel Data Fetching with Component Composition
React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
**Incorrect (Sidebar waits for Page's fetch to complete):**
```tsx
export default async function Page() {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
```
**Correct (both fetch simultaneously):**
```tsx
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
)
}
```
**Alternative with children prop:**
```tsx
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
function Layout({ children }: { children: ReactNode }) {
return (
<div>
<Header />
{children}
</div>
)
}
export default function Page() {
return (
<Layout>
<Sidebar />
</Layout>
)
}
```
@@ -0,0 +1,38 @@
---
title: Minimize Serialization at RSC Boundaries
impact: HIGH
impactDescription: reduces data transfer size
tags: server, rsc, serialization, props
---
## Minimize Serialization at RSC Boundaries
The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.
**Incorrect (serializes all 50 fields):**
```tsx
async function Page() {
const user = await fetchUser() // 50 fields
return <Profile user={user} />
}
'use client'
function Profile({ user }: { user: User }) {
return <div>{user.name}</div> // uses 1 field
}
```
**Correct (serializes only 1 field):**
```tsx
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
}
'use client'
function Profile({ name }: { name: string }) {
return <div>{name}</div>
}
```
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/vercel-react-best-practices
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/web-design-guidelines
-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);

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