Compare commits

..
1 Commits
Author SHA1 Message Date
Marc Seitz 4e720bb2b6 chore: update deps 2025-07-21 22:38:09 +02:00
762 changed files with 13586 additions and 70950 deletions
+1
View File
@@ -0,0 +1 @@
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 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 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 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
+295 -358
View File
@@ -34,252 +34,209 @@
},
"black": {
"hashes": [
"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"
"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"
],
"version": "==25.12.0"
"version": "==24.10.0"
},
"certifi": {
"hashes": [
"sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
"sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
"sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56",
"sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"
],
"markers": "python_version >= '3.7'",
"version": "==2025.11.12"
"markers": "python_version >= '3.6'",
"version": "==2024.12.14"
},
"cffi": {
"hashes": [
"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"
"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"
],
"markers": "python_version >= '3.9'",
"version": "==2.0.0"
"markers": "python_version >= '3.8'",
"version": "==1.17.1"
},
"charset-normalizer": {
"hashes": [
"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"
"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"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.4"
"version": "==3.4.1"
},
"click": {
"hashes": [
@@ -291,22 +248,30 @@
},
"clickhouse-toolset": {
"hashes": [
"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"
"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"
],
"markers": "python_version < '3.14' and python_version >= '3.8'",
"version": "==0.34.dev0"
"markers": "python_version < '3.13' and python_version >= '3.8'",
"version": "==0.33.dev0"
},
"colorama": {
"hashes": [
@@ -318,11 +283,11 @@
},
"croniter": {
"hashes": [
"sha256:924a38fda88f675ec6835667e1d32ac37ff0d65509c2152729d16ff205e32a65",
"sha256:f17f877be1d93b9e3191151584a19d8b367b017ab0febc8c5472b9300da61c4c"
"sha256:32a5ec04e97ec0837bcdf013767abd2e71cceeefd3c2e14c804098ce51ad6cd9",
"sha256:d6ed8386d5f4bbb29419dc1b65c4909c04a2322bd15ec0dc5b2877bfa1b75c7a"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.3.15"
"version": "==1.3.8"
},
"cryptography": {
"hashes": [
@@ -363,11 +328,11 @@
},
"gitpython": {
"hashes": [
"sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c",
"sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"
"sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110",
"sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"
],
"markers": "python_version >= '3.7'",
"version": "==3.1.45"
"version": "==3.1.44"
},
"humanfriendly": {
"hashes": [
@@ -379,19 +344,19 @@
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
"markers": "python_version >= '3.6'",
"version": "==3.10"
},
"mypy-extensions": {
"hashes": [
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
"sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
"sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
],
"markers": "python_version >= '3.8'",
"version": "==1.1.0"
"markers": "python_version >= '3.5'",
"version": "==1.0.0"
},
"packaging": {
"hashes": [
@@ -419,11 +384,11 @@
},
"pycparser": {
"hashes": [
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
"sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
],
"markers": "implementation_name != 'PyPy'",
"version": "==2.23"
"markers": "python_version >= '3.8'",
"version": "==2.22"
},
"pydantic": {
"hashes": [
@@ -542,100 +507,72 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.9.0.post0"
},
"pytokens": {
"hashes": [
"sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a",
"sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"
],
"markers": "python_version >= '3.8'",
"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"
"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"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
"version": "==6.0.2"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
"sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
"sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
],
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
"markers": "python_version >= '3.8'",
"version": "==2.32.3"
},
"shandy-sqlfmt": {
"hashes": [
@@ -663,12 +600,12 @@
},
"tinybird-cli": {
"hashes": [
"sha256:a1d0332d4c8f8ef16cedbed6e5a872d5f372604a6bdc6bb9872ebea8e33e0fda",
"sha256:f2c4a95f9e4f79cd7bdc39fef1ec093cba2289b7d6a43d888eff26f1b860c370"
"sha256:5ea2c759fff89d5a1104cdfdd5fd8e2bfde68445ea8635e1ef1031403e558863",
"sha256:7e855f9338b2e982237e25bb3ad9f0e36c9305ab546c01c68df6c31ad59b2e05"
],
"index": "pypi",
"markers": "python_version < '3.14' and python_version >= '3.9'",
"version": "==5.21.1"
"markers": "python_version < '3.13' and python_version >= '3.9'",
"version": "==5.13.1"
},
"toposort": {
"hashes": [
@@ -702,11 +639,11 @@
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
],
"markers": "python_version < '3.13'",
"version": "==4.15.0"
"version": "==4.12.2"
},
"urllib3": {
"hashes": [
-11
View File
@@ -1,11 +0,0 @@
# 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,8 +8,9 @@ import { waitUntil } from "@vercel/functions";
import { hashToken } from "@/lib/api/auth/token";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { sendEmail, subscribe, unsubscribe } from "@/lib/resend";
import { sendEmail } from "@/lib/resend";
import { CustomUser } from "@/lib/types";
import { subscribe, unsubscribe } from "@/lib/unsend";
import EmailUpdated from "@/components/emails/email-updated";
@@ -1,339 +0,0 @@
"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>
);
}
@@ -1,41 +0,0 @@
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 />;
}
+12 -17
View File
@@ -1,7 +1,8 @@
"use client";
import { Metadata } from "next";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useState } from "react";
@@ -23,7 +24,6 @@ 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,22 +85,15 @@ export default function Login() {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.ok && !res?.error) {
setEmail("");
setLastUsed("credentials");
// 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");
setEmailButtonText("Email sent - check your inbox!");
toast.success("Email sent - check your inbox!");
} else {
setEmailButtonText("Error sending email - try again?");
toast.error("Error sending email - try again?");
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
>
@@ -152,7 +145,9 @@ export default function Login() {
signIn("google", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
setClickedMethod(undefined);
if (res?.status) {
setClickedMethod(undefined);
}
});
}}
loading={clickedMethod === "google"}
@@ -174,7 +169,9 @@ export default function Login() {
signIn("linkedin", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
setClickedMethod(undefined);
if (res?.status) {
setClickedMethod(undefined);
}
});
}}
loading={clickedMethod === "linkedin"}
@@ -195,8 +192,6 @@ export default function Login() {
setClickedMethod("passkey");
signInWithPasskey({
tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,
}).then(() => {
setClickedMethod(undefined);
});
}}
variant="outline"
+1 -8
View File
@@ -1,7 +1,5 @@
import { Metadata } from "next";
import { GTMComponent } from "@/components/gtm-component";
import LoginClient from "./page-client";
const data = {
@@ -39,10 +37,5 @@ export const metadata: Metadata = {
};
export default function LoginPage() {
return (
<>
<GTMComponent />
<LoginClient />
</>
);
return <LoginClient />;
}
+177 -4
View File
@@ -1,6 +1,179 @@
import { redirect } from "next/navigation";
import { Metadata } from "next";
import Link from "next/link";
// Legacy verify page - redirect to new auth email flow
export default async function VerifyPage() {
redirect("/auth/email");
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>
);
}
-1
View File
@@ -1 +0,0 @@
../ee/LICENSE.md
-1
View File
@@ -1 +0,0 @@
../ee/README.md
@@ -1,153 +0,0 @@
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
@@ -1,146 +0,0 @@
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
@@ -1,590 +0,0 @@
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 },
);
}
}
@@ -1,56 +0,0 @@
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 },
);
}
}
@@ -1,253 +0,0 @@
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 },
);
}
}
@@ -1,300 +0,0 @@
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 },
);
}
}
@@ -1,86 +0,0 @@
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
@@ -1,133 +0,0 @@
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 },
);
}
}
@@ -1,396 +0,0 @@
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,
});
}
@@ -1,285 +0,0 @@
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 },
);
}
}
@@ -1,142 +0,0 @@
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 },
);
}
}
@@ -1,134 +0,0 @@
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 },
);
}
}
@@ -1,320 +0,0 @@
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 },
);
}
}
@@ -1,359 +0,0 @@
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 },
);
}
}
@@ -1,440 +0,0 @@
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
@@ -1,287 +0,0 @@
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
@@ -1,98 +0,0 @@
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 },
);
}
}
+2 -1
View File
@@ -37,8 +37,9 @@ export async function POST(req: Request) {
const domains = await prisma.domain.findMany({
where: {
slug: {
// exclude domains that belong to us
not: {
in: ["papermark.io", "papermark.com"],
contains: "papermark.io",
},
},
},
+158
View File
@@ -0,0 +1,158 @@
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
@@ -1,56 +0,0 @@
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 },
);
}
}
@@ -1,51 +0,0 @@
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 },
);
}
}
@@ -1,115 +0,0 @@
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
@@ -1,6 +0,0 @@
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));
}
+1 -98
View File
@@ -1,6 +1,5 @@
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";
@@ -17,10 +16,6 @@ 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";
@@ -103,8 +98,6 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -113,7 +106,6 @@ export async function POST(request: NextRequest) {
password: true,
domainSlug: true,
isArchived: true,
deletedAt: true,
slug: true,
domainId: true,
allowList: true,
@@ -132,8 +124,6 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -143,12 +133,6 @@ export async function POST(request: NextRequest) {
},
},
enableUpload: true,
dataroom: {
select: {
agentsEnabled: true,
name: true,
},
},
},
});
@@ -163,13 +147,6 @@ 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
@@ -309,8 +286,6 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -323,8 +298,6 @@ 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 },
@@ -341,8 +314,6 @@ 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 },
@@ -385,7 +356,6 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -400,21 +370,6 @@ 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}`,
);
@@ -662,11 +617,6 @@ 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");
@@ -695,28 +645,8 @@ 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 =
@@ -733,8 +663,6 @@ 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 }),
};
@@ -814,7 +742,6 @@ export async function POST(request: NextRequest) {
dataroomId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
}
@@ -831,26 +758,6 @@ 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
@@ -914,7 +821,6 @@ 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 },
@@ -1011,8 +917,7 @@ export async function POST(request: NextRequest) {
(documentVersion.type === "pdf" ||
documentVersion.type === "image" ||
documentVersion.type === "zip" ||
documentVersion.type === "video" ||
documentVersion.type === "link")) ||
documentVersion.type === "video")) ||
(documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
@@ -1050,8 +955,6 @@ 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 }),
};
+2 -70
View File
@@ -1,6 +1,5 @@
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]";
@@ -14,14 +13,13 @@ 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 { isEmailMatched } from "@/lib/utils/email-domain";
import { extractEmailDomain, 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";
@@ -86,16 +84,12 @@ 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,
@@ -108,8 +102,6 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -118,11 +110,6 @@ export async function POST(request: NextRequest) {
label: true,
},
},
document: {
select: {
agentsEnabled: true,
},
},
},
});
@@ -139,13 +126,6 @@ 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
@@ -241,8 +221,6 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -255,8 +233,6 @@ 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 },
@@ -273,8 +249,6 @@ 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 },
@@ -288,21 +262,6 @@ 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}`,
);
@@ -653,11 +612,6 @@ 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(
@@ -670,41 +624,20 @@ 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 === "link")) ||
documentVersion.type === "video")) ||
(documentVersion && useAdvancedExcelViewer)
? documentVersion.file
: undefined,
@@ -735,7 +668,6 @@ export async function POST(request: NextRequest) {
: undefined,
verificationToken: hashedVerificationToken ?? undefined,
...(isTeamMember && { isTeamMember: true }),
...(agentsEnabled && { agentsEnabled: true }),
};
return NextResponse.json(returnObject);
+8
View File
@@ -1,6 +1,8 @@
import { Metadata } from "next";
import { Inter } from "next/font/google";
import PlausibleProvider from "next-plausible";
import "@/styles/globals.css";
const inter = Inter({ subsets: ["latin"] });
@@ -47,6 +49,12 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<head>
<PlausibleProvider
domain="papermark.io"
enabled={process.env.NEXT_PUBLIC_VERCEL_ENV === "production"}
/>
</head>
<body className={inter.className}>{children}</body>
</html>
);
+5 -5
View File
@@ -1,12 +1,13 @@
import { useOptimisticUpdate } from "@/components/hooks/use-optimistic-update";
import { Switch } from "@/components/ui/switch";
import { useOptimisticUpdate } from "../hooks/use-optimistic-update";
import { Switch } from "../ui/switch";
// TODO: MAIL SUBSCRIPTION FOR LATER USE
export const UpdateMailSubscribe = () => {
const { data, isLoading, update } = useOptimisticUpdate<{
subscribed: boolean;
}>("/api/user/subscribe", {
loading: "Updating email preferences...",
success: "Your email preferences have been updated!",
success: `Your ${process.env.NEXT_PUBLIC_BASE_URL} email preferences has been updated!`,
error: "Failed to update email preferences. Please try again.",
});
@@ -24,8 +25,7 @@ export const UpdateMailSubscribe = () => {
return (
<div className="flex items-center gap-x-2">
<Switch
checked={data?.subscribed ?? true}
disabled={isLoading}
checked={data?.subscribed}
onCheckedChange={(checked: boolean) => {
update(() => subscribe(checked), { subscribed: checked });
}}
-100
View File
@@ -1,100 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
-448
View File
@@ -1,448 +0,0 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[80%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom" className="px-1.5 py-1 text-sm">
<span>{tooltip}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null,
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch",
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className,
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:m-0 [&_li>p]:inline",
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className,
)}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute right-2 top-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className,
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className,
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
"use client";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
hoverOnly?: boolean;
};
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
hoverOnly = false,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
);
const animationProps = hoverOnly
? {
initial: { backgroundPosition: "100% center" },
whileHover: { backgroundPosition: "0% center" },
}
: {
initial: { backgroundPosition: "100% center" },
animate: { backgroundPosition: "0% center" },
};
const transitionProps = hoverOnly
? {
duration,
ease: "linear" as const,
}
: {
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear" as const,
};
return (
<MotionComponent
{...animationProps}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))",
} as CSSProperties
}
transition={transitionProps}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+19 -24
View File
@@ -34,14 +34,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { usePlan } from "@/lib/swr/use-billing";
import { fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Document {
id: string;
@@ -152,20 +151,11 @@ const columns: ColumnDef<Document>[] = [
</Button>
);
},
cell: ({ row }) =>
row.original.lastViewed ? (
<TimestampTooltip
timestamp={row.original.lastViewed}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.lastViewed)}
</div>
</TimestampTooltip>
) : (
<div className="text-sm text-muted-foreground">-</div>
),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
</div>
),
},
];
@@ -231,15 +221,20 @@ export default function DocumentsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -256,7 +251,7 @@ export default function DocumentsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+24 -29
View File
@@ -27,11 +27,6 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Table,
@@ -41,10 +36,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { UpgradeButton } from "../ui/upgrade-button";
import { usePlan } from "@/lib/swr/use-billing";
import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Link {
id: string;
@@ -214,20 +213,11 @@ const columns: ColumnDef<Link>[] = [
</Button>
);
},
cell: ({ row }) =>
row.original.lastViewed ? (
<TimestampTooltip
timestamp={row.original.lastViewed}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.lastViewed)}
</div>
</TimestampTooltip>
) : (
<div className="text-sm text-muted-foreground">-</div>
),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
</div>
),
},
];
@@ -295,15 +285,20 @@ export default function LinksTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -320,7 +315,7 @@ export default function LinksTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+1 -1
View File
@@ -16,7 +16,7 @@ import {
import { cn } from "@/lib/utils";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
const TIME_RANGES = [
{ value: "24h", label: "Last 24 hours", shortcut: "D" },
+34 -44
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
@@ -16,7 +16,6 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
BadgeInfoIcon,
ChevronDownIcon,
@@ -25,6 +24,8 @@ import {
Download,
DownloadCloudIcon,
FileBadgeIcon,
FileDigitIcon,
MoreHorizontalIcon,
ServerIcon,
ThumbsDownIcon,
ThumbsUpIcon,
@@ -37,6 +38,11 @@ import { cn, durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
@@ -46,12 +52,16 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import VisitorChart from "@/components/visitors/visitor-chart";
import VisitorClicks from "@/components/visitors/visitor-clicks";
import VisitorCustomFields from "@/components/visitors/visitor-custom-fields";
import VisitorUserAgent from "@/components/visitors/visitor-useragent";
import VisitorVideoChart from "@/components/visitors/visitor-video-chart";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface View {
id: string;
@@ -76,7 +86,7 @@ interface View {
const columns: ColumnDef<View>[] = [
{
accessorKey: "viewerEmail",
header: "Recent Views",
header: "Recent Visits",
cell: ({ row }) => (
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={row.original.viewerEmail} />
@@ -249,7 +259,7 @@ const columns: ColumnDef<View>[] = [
"px-0",
)}
>
Last Viewed
Last Visited
{column.getIsSorted() === "asc" ? (
<ChevronUpIcon className="ml-2 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
@@ -261,15 +271,9 @@ const columns: ColumnDef<View>[] = [
);
},
cell: ({ row }) => (
<TimestampTooltip
timestamp={row.original.viewedAt}
side="right"
rows={["local", "utc", "unix"]}
>
<div className="select-none text-sm text-muted-foreground">
{timeAgo(row.original.viewedAt)}
</div>
</TimestampTooltip>
<div className="text-sm text-muted-foreground">
{timeAgo(row.original.viewedAt)}
</div>
),
},
];
@@ -283,13 +287,13 @@ export default function ViewsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree, isPaused } = usePlan();
const { isTrial, isFree } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "viewedAt", desc: true },
]);
const { data } = useSWR<{ views: View[]; hiddenFromPause: number }>(
const { data: views } = useSWR<View[]>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=views&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -300,9 +304,6 @@ export default function ViewsTable({
},
);
const views = data?.views;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: views || [],
columns,
@@ -340,15 +341,20 @@ export default function ViewsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -362,22 +368,6 @@ export default function ViewsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<Link
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</Link>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
@@ -448,7 +438,7 @@ export default function ViewsTable({
colSpan={columns.length}
className="h-24 text-center"
>
<p>No views in the last {interval}</p>
<p>No visits in the last {interval}</p>
</TableCell>
</TableRow>
)}
+22 -43
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
@@ -16,7 +15,6 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
ChevronDownIcon,
ChevronUpIcon,
@@ -27,11 +25,8 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
TableBody,
@@ -44,7 +39,11 @@ import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { UpgradeButton } from "../ui/upgrade-button";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Visitor {
email: string;
@@ -54,7 +53,6 @@ interface Visitor {
uniqueDocuments: number;
verified: boolean;
totalDuration: number;
viewerName?: string | null;
}
const columns: ColumnDef<Visitor>[] = [
@@ -67,18 +65,13 @@ const columns: ColumnDef<Visitor>[] = [
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{row.original.viewerName || row.original.email}{" "}
{row.original.email}{" "}
{row.original.verified && (
<BadgeTooltip content="Verified visitor">
<BadgeCheckIcon className="h-4 w-4 text-emerald-500 hover:text-emerald-600" />
</BadgeTooltip>
)}
</p>
{row.original.viewerName && row.original.email && (
<p className="text-xs text-muted-foreground/60">
{row.original.email}
</p>
)}
<p className="text-sm text-muted-foreground">
{row.original.uniqueDocuments} document
{row.original.uniqueDocuments !== 1 ? "s" : ""} viewed
@@ -223,13 +216,13 @@ export default function VisitorsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree, isPaused } = usePlan();
const { isTrial, isFree } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "lastActive", desc: true },
]);
const { data } = useSWR<{ visitors: Visitor[]; hiddenFromPause: number }>(
const { data: visitors } = useSWR<Visitor[]>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=visitors&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -240,9 +233,6 @@ export default function VisitorsTable({
},
);
const visitors = data?.visitors;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: visitors || [],
columns,
@@ -279,15 +269,20 @@ export default function VisitorsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
variant="outline"
size="sm"
/>
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
open={open}
setOpen={setOpen}
/>
</>
);
} else {
return (
@@ -301,26 +296,10 @@ export default function VisitorsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<Link
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</Link>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="overflow-x-auto rounded-xl border">
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+6 -15
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -38,18 +37,17 @@ export function AddSeatModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const analytics = useAnalytics();
const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = usePlan();
const { plan: userPlan, planName, isAnnualPlan } = usePlan();
const { limits } = useLimits();
const [quantity, setQuantity] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
// Get the minimum quantity for the current plan
const priceId = getPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period: isAnnualPlan ? "yearly" : "monthly",
});
const priceId = getPriceIdFromPlan(
planName,
isAnnualPlan ? "yearly" : "monthly",
);
const minQuantity = getQuantityFromPriceId(priceId);
// Set initial quantity to 1 (adding one seat)
@@ -181,17 +179,10 @@ export function AddSeatModal({
)}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:justify-center">
<DialogFooter>
<Button onClick={handleSubmit} className="w-full" disabled={loading}>
{loading ? "Redirecting..." : "Proceed to checkout"}
</Button>
<Link
href="/settings/upgrade"
className="block w-full text-center text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
onClick={() => setOpen(false)}
>
or upgrade to higher plan
</Link>
</DialogFooter>
</DialogContent>
</Dialog>
+6 -7
View File
@@ -5,18 +5,20 @@ import { Dispatch, SetStateAction, useState } from "react";
import { useTeam } from "@/context/team-context";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import { toast } from "sonner";
import { usePlan } from "@/lib/swr/use-billing";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
import { usePlan } from "@/lib/swr/use-billing";
export default function ProAnnualBanner({
setShowProAnnualBanner,
}: {
setShowProAnnualBanner: Dispatch<SetStateAction<boolean | null>>;
}) {
const plausible = usePlausible();
const router = useRouter();
const teamInfo = useTeam();
const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();
@@ -25,6 +27,7 @@ export default function ProAnnualBanner({
const handleHideBanner = () => {
setShowProAnnualBanner(false);
plausible("clickedHideProAnnualBanner");
Cookies.set("hideProAnnualBanner", "pro-annual-banner", {
expires: 7,
});
@@ -60,11 +63,7 @@ export default function ProAnnualBanner({
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId: getPriceIdFromPlan({
planSlug: "pro",
isOld: isOldAccount,
period: "yearly",
}),
priceId: getPriceIdFromPlan("Pro", "yearly"),
upgradePlan: true,
proAnnualBanner: true,
}),
+12 -2
View File
@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
@@ -13,8 +14,11 @@ export default function ProBanner({
}: {
setShowProBanner: Dispatch<SetStateAction<boolean | null>>;
}) {
const plausible = usePlausible();
const handleHideBanner = () => {
setShowProBanner(false);
plausible("clickedHideBanner");
Cookies.set("hideProBanner", "pro-banner", {
expires: 7,
});
@@ -38,10 +42,16 @@ export default function ProBanner({
</p>
<div className="flex">
<UpgradePlanModal
clickedPlan={PlanEnum.Business}
clickedPlan={PlanEnum.Pro}
trigger={"pro_banner"}
>
<Button type="button" className="grow">
<Button
type="button"
className="grow"
onClick={() => {
plausible("clickedProBanner");
}}
>
Upgrade
</Button>
</UpgradePlanModal>
+44 -317
View File
@@ -1,22 +1,9 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import {
BanIcon,
CirclePauseIcon,
CreditCardIcon,
MoreVertical,
ReceiptTextIcon,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
@@ -27,59 +14,27 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UpgradeButton } from "@/components/ui/upgrade-button";
import { usePlan } from "@/lib/swr/use-billing";
export default function UpgradePlanContainer() {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);
const [cancellationModalOpen, setCancellationModalOpen] =
useState<boolean>(false);
const { currentTeamId } = useTeam();
const {
plan,
isFree,
isDataroomsPlus,
isDataroomsPremium,
isPaused,
isCancelled,
isCustomer,
startsAt,
endsAt,
pauseStartsAt,
pauseEndsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
const analytics = useAnalytics();
const teamInfo = useTeam();
const { plan, isFree, isDataroomsPlus } = usePlan();
const manageSubscription = async ({
type,
}: {
type:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update"
| "cancellation";
}) => {
if (!currentTeamId) return;
const manageSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
return;
}
const teamId = teamInfo?.currentTeam?.id;
setLoading(true);
try {
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
fetch(`/api/teams/${teamId}/billing/manage`, {
method: "POST",
body: JSON.stringify({ type }),
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
const url = await res.json();
@@ -98,276 +53,48 @@ export default function UpgradePlanContainer() {
}
};
const handleUnpauseSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/unpause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to unpause subscription");
}
// Track the unpause event for analytics
analytics.capture("Subscription Unpaused", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription unpaused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const handleReactivateSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/reactivate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to reactivate subscription");
}
// Track the reactivation event for analytics
analytics.capture("Subscription Reactivated", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription reactivated successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const isBillingCycleCurrent = () => {
if (!startsAt || !endsAt) return false;
const currentDate = new Date();
return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);
};
const getDiscountText = () => {
if (!discount || !discount.valid) return null;
let discountText = "";
if (discount.percentOff) {
discountText = `${discount.percentOff}% off`;
} else if (discount.amountOff) {
discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;
}
if (discount.duration === "repeating" && discount.durationInMonths) {
discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? "s" : ""}`;
} else if (discount.duration === "once") {
discountText += " (one-time)";
}
return discountText;
};
const ButtonList = () => {
if (isFree) {
return (
<div className="flex items-center gap-3">
<UpgradeButton
text=""
customText="Upgrade"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
</div>
);
} else if (isCancelled) {
return (
<Button onClick={handleReactivateSubscription} loading={unpauseLoading}>
Reactivate subscription
</Button>
<Link href="/settings/upgrade">
<Button>Upgrade Plan</Button>
</Link>
);
} else {
return (
<div className="flex items-center gap-3">
{isPaused ? (
<>
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "cancellation" })}
className="text-red-500"
>
<BanIcon className="h-4 w-4" />
Cancel subscription
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
>
Cancel subscription
</Button>
<Button
variant="outline"
onClick={() =>
manageSubscription({ type: "subscription_update" })
}
loading={loading}
>
Change plan
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "manage" })}
>
<CreditCardIcon className="h-4 w-4" />
Change billing information
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
<Button onClick={manageSubscription} loading={loading}>
Manage Subscription
</Button>
);
}
};
return (
<>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>
{isDataroomsPremium
? "Premium"
: isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}{" "}
Plan
</CardTitle>
{!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (
<CardDescription>
<span className="font-medium text-foreground">
Current billing cycle:{" "}
</span>
<span className="text-foreground">
{new Date(startsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{" - "}
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isPaused && pauseStartsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription{" "}
{new Date(pauseStartsAt) > new Date()
? "will pause on"
: "paused on"}
:{" "}
</span>
<span className="text-foreground">
{new Date(pauseStartsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isCancelled && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription cancels on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{discount && discount.valid && getDiscountText() && (
<CardDescription>
<div className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
🎉 {getDiscountText()} applied
</div>
</CardDescription>
)}
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
<CancellationModal
open={cancellationModalOpen}
onOpenChange={setCancellationModalOpen}
/>
</>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Billing</CardTitle>
<CardDescription>
You are currently on the{" "}
<span className="mx-0.5 rounded-full bg-background px-2.5 py-1 text-xs font-bold tracking-normal text-foreground ring-1 ring-gray-800 dark:ring-gray-400">
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}
</span>{" "}
plan.{" "}
<Link
href="/settings/upgrade"
className="text-sm underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
</CardDescription>
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
);
}
+1 -12
View File
@@ -106,19 +106,8 @@ export function UpgradePlanModal({
trigger: trigger,
teamId: teamInfo?.currentTeam?.id,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamInfo?.currentTeam?.id) {
fetch(`/api/teams/${teamInfo.currentTeam.id}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
}
}, [open, trigger, teamInfo?.currentTeam?.id]);
}, [open, trigger]);
// Track analytics event when child button is present
const handleUpgradeClick = () => {
@@ -1,576 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useMemo, useState } from "react";
import React from "react";
import { useTeam } from "@/context/team-context";
import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import {
CheckIcon,
CircleHelpIcon,
Sparkles,
Users2Icon,
XIcon,
} from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { capitalize, cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push("/welcome?type=dataroom-trial");
};
return (
<span
onClick={handleStartTrial}
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
>
Start free data room trial
</span>
);
};
// Feature rendering component
const FeatureItem = ({ feature }: { feature: Feature }) => {
const baseClasses = `flex items-center ${feature.isHighlighted ? "bg-orange-50 -mx-6 px-6 py-2 -my-1 font-bold rounded-md dark:bg-orange-900/20" : ""}`;
if (feature.isUsers) {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<span>{feature.text}</span>
</div>
{feature.tooltip && (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<div className="cursor-help">
<Users2Icon className="h-4 w-4 text-gray-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{feature.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}
return (
<div className={cn("text-sm", baseClasses)}>
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
<BadgeTooltip content={feature.tooltip}>
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
</BadgeTooltip>
)}
</div>
</div>
);
};
// Segmented control component for Base/Plus/Premium selection
const PlanSelector = ({
value,
onChange,
}: {
value: "base" | "plus" | "premium";
onChange: (value: "base" | "plus" | "premium") => void;
}) => {
return (
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "base"
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("base")}
>
Base
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "plus"
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("plus")}
>
Plus
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "premium"
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("premium")}
>
Premium
</button>
</div>
);
};
export function UpgradePlanModalWithDiscount({
clickedPlan,
trigger,
open,
setOpen,
highlightItem,
children,
}: {
clickedPlan: PlanEnum;
trigger?: string;
open?: boolean;
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
highlightItem?: string[];
children?: React.ReactNode;
}) {
const router = useRouter();
const [period, setPeriod] = useState<"yearly" | "monthly">("yearly");
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const {
plan: teamPlan,
isCustomer,
isOldAccount,
isTrial,
isFree,
} = usePlan();
const analytics = useAnalytics();
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
"base" | "plus" | "premium"
>("base");
// Get next higher plan for current user
const getNextHigherPlan = () => {
if (isFree) return PlanEnum.Pro; // Free users see Pro as next
if (teamPlan === "pro") return PlanEnum.Business;
if (teamPlan === "business") return PlanEnum.DataRooms;
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
return null;
};
const nextHigherPlan = getNextHigherPlan();
const plansToShow = useMemo(() => {
switch (clickedPlan) {
case PlanEnum.Pro:
return [PlanEnum.Pro, PlanEnum.Business];
case PlanEnum.Business:
return [PlanEnum.Business, PlanEnum.DataRooms];
case PlanEnum.DataRooms:
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
case PlanEnum.DataRoomsPlus:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
case PlanEnum.DataRoomsPremium:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
default:
return [PlanEnum.Pro, PlanEnum.Business];
}
}, [clickedPlan]);
// Track analytics event when modal is opened
useEffect(() => {
if (open) {
analytics.capture("Upgrade Button Clicked", {
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setDataRoomsPlanSelection("base");
}
}, [open, trigger, teamId]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
trigger: trigger,
teamId,
});
};
// If button is present, clone it and add onClick handler
const buttonChild = React.isValidElement<{
onClick?: React.MouseEventHandler<HTMLButtonElement>;
}>(children)
? React.cloneElement(children, { onClick: handleUpgradeClick })
: children;
// Calculate 30% discounted yearly price
const getDiscountedYearlyPrice = (yearlyPrice: number) => {
return Math.round(yearlyPrice * 0.7); // Round to whole number
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{buttonChild}</DialogTrigger>
<DialogContent
className="max-h-[90vh] min-h-fit overflow-y-auto bg-white p-0 text-foreground dark:bg-gray-900"
style={{
width: "95vw",
maxWidth: "1200px",
}}
>
<div className="flex flex-col lg:flex-row">
{/* Left Sidebar - Discount Information */}
<div className="flex h-full w-full flex-col border-b border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 lg:w-64 lg:border-b-0 lg:border-r">
<div className="mb-2">
<h2 className="text-sm font-bold uppercase text-[#fb7a00]">
SWITCH TO YEARLY
</h2>
</div>
<p className="mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
Save <span className="font-bold text-[#fb7a00]">30%</span> on
yearly plans
</p>
{/* Decorative elements - Holiday section - Centered vertically */}
<div className="relative flex flex-1 flex-col items-center justify-center">
<div className="mb-3 h-16 w-16 text-[#fb7a00] opacity-30">
<Sparkles className="h-full w-full" />
</div>
<div className="flex w-full items-center gap-2">
<div className="h-px flex-1 bg-[#fb7a00]"></div>
<p className="text-sm font-semibold text-[#fb7a00]">NEW YEAR&apos;S</p>
<div className="h-px flex-1 bg-[#fb7a00]"></div>
</div>
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
New Year&apos;s offer
</p>
{/* <div className="absolute bottom-0 h-12 w-12 text-[#fb7a00] opacity-20">
<Sparkles className="h-full w-full" />
</div> */}
</div>
<p className="mt-6 text-center text-xs text-gray-500 dark:text-gray-400">
Ends Jan 15 2026
</p>
</div>
{/* Right Content - Plans */}
<div className="flex-1 p-6">
{/* Monthly/Yearly Toggle */}
<div className="mb-6 flex items-center justify-center">
<span className="mr-2 text-sm">
Monthly{" "}
{period === "monthly" && (
<span className="text-[#fb7a00]"></span>
)}
</span>
<Switch
checked={period === "yearly"}
onCheckedChange={() =>
setPeriod(period === "monthly" ? "yearly" : "monthly")
}
/>
<span className="ml-2 text-sm">Annually</span>
</div>
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
{plansToShow.map((planOption) => {
const isDataRoomsUpgrade = plansToShow.includes(
PlanEnum.DataRooms,
);
// Determine which plan to show based on selection for Data Rooms
let effectivePlan = planOption;
let displayPlanName = planOption;
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
if (dataRoomsPlanSelection === "plus") {
effectivePlan = PlanEnum.DataRoomsPlus;
displayPlanName = PlanEnum.DataRoomsPlus;
} else if (dataRoomsPlanSelection === "premium") {
effectivePlan = PlanEnum.DataRoomsPremium;
displayPlanName = PlanEnum.DataRoomsPremium;
}
}
const planFeatures = getPlanFeatures(effectivePlan, {
period,
});
const planData = PLANS.find((p) => p.name === displayPlanName);
const monthlyPrice = planData?.price.monthly.amount || 0;
const yearlyPrice = planData?.price.yearly.amount || 0;
const discountedYearlyPrice =
getDiscountedYearlyPrice(yearlyPrice);
// Determine if this plan should have orange styling
const isOrangePlan = isFree
? displayPlanName === PlanEnum.Business ||
displayPlanName === PlanEnum.DataRoomsPlus
: displayPlanName === nextHigherPlan;
return (
<div
key={displayPlanName}
className={`relative flex flex-col rounded-lg border ${
isOrangePlan
? "border-[#fb7a00]"
: displayPlanName === PlanEnum.DataRoomsPremium
? "border-gray-900"
: "border-gray-200"
} bg-white p-6 shadow-sm dark:bg-gray-900`}
>
<div className="mb-4 border-b border-gray-200 pb-2">
<div className="flex items-center justify-between">
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
{displayPlanName}
</h3>
</div>
{period === "yearly" &&
(() => {
const monthlyForYear = monthlyPrice * 12;
const yearlyWithDiscount = Math.round(
yearlyPrice * 12 * 0.7,
);
const savings = monthlyForYear - yearlyWithDiscount;
return savings > 0 ? (
<span className="absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]">
Save {savings}/year
</span>
) : null;
})()}
{period === "monthly" && isOrangePlan && (
<span className="absolute right-2 top-2 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white">
{displayPlanName === PlanEnum.Business &&
"Most popular"}
{displayPlanName === PlanEnum.DataRoomsPlus &&
"Best deal"}
</span>
)}
</div>
<div className="mb-2">
{period === "yearly" ? (
// Yearly: Show discounted price with crossed out original
<>
<div className="flex items-baseline gap-2">
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
{discountedYearlyPrice}
</span>
<span className="text-xl text-gray-500 line-through">
{yearlyPrice}
</span>
</div>
<span className="text-gray-500 dark:text-white/75">
/month, billed annually
</span>
</>
) : (
// Monthly: Show regular monthly price (no discount)
<>
<div className="flex items-baseline gap-2">
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
{monthlyPrice}
</span>
</div>
<span className="text-gray-500 dark:text-white/75">
/month
</span>
</>
)}
</div>
{planOption === PlanEnum.DataRooms &&
isDataRoomsUpgrade &&
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
<PlanSelector
value={dataRoomsPlanSelection}
onChange={setDataRoomsPlanSelection}
/>
)}
<p className="mt-4 text-sm text-gray-600 dark:text-white">
{planFeatures.featureIntro}
</p>
<ul className="mb-6 mt-2 space-y-2 text-sm leading-6 text-gray-600 dark:text-muted-foreground">
{planFeatures.features.map((feature, i) => (
<li key={i}>
<FeatureItem
feature={{
...feature,
isHighlighted: highlightItem?.includes(
feature.id,
),
}}
/>
</li>
))}
</ul>
<div className="mt-auto">
<Button
variant={isOrangePlan ? "default" : "outline"}
className={`w-full py-2 text-sm ${
isOrangePlan
? "bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]"
: "bg-gray-800 text-white hover:bg-gray-900 hover:text-white dark:hover:bg-gray-700/80"
}`}
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
period,
isOld: isOldAccount,
});
setSelectedPlan(planOption);
// Apply 30% coupon only for yearly plans
// Uses same logic as retention flow: getCouponFromPlan() in API routes
const applyDiscount = period === "yearly";
if (isCustomer && teamPlan !== "free") {
// For existing customers: Apply coupon directly to subscription (same as retention flow)
fetch(`/api/teams/${teamId}/billing/manage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId,
upgradePlan: true,
applyYearlyDiscount: applyDiscount, // Only true for yearly plans
}),
})
.then(async (res) => {
if (!res.ok) {
const errorData = await res
.json()
.catch(() => null);
const errorMessage =
errorData?.error ||
errorData?.message ||
res.statusText ||
"Failed to upgrade plan";
throw new Error(errorMessage);
}
const url = await res.json();
router.push(url);
})
.catch((err) => {
alert(err.message || err);
setSelectedPlan(null);
});
} else {
// For new customers: Add coupon to Stripe checkout session
fetch(
`/api/teams/${teamId}/billing/upgrade?priceId=${priceId}${applyDiscount ? "&applyYearlyDiscount=true" : ""}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
)
.then(async (res) => {
if (!res.ok) {
const errorData = await res
.json()
.catch(() => null);
const errorMessage =
errorData?.error ||
errorData?.message ||
res.statusText ||
"Failed to start checkout";
throw new Error(errorMessage);
}
const data = await res.json();
const { id: sessionId } = data;
const stripe = await getStripe(isOldAccount);
stripe?.redirectToCheckout({ sessionId });
})
.catch((err) => {
alert(err.message || err);
setSelectedPlan(null);
});
}
}}
>
{selectedPlan === planOption
? "Redirecting to Stripe..."
: `Upgrade to ${displayPlanName} ${capitalize(period)}`}
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-6 flex flex-col items-center text-center text-sm text-muted-foreground">
All plans include unlimited visitors and page by page document
analytics.
<div className="flex items-center gap-2">
<Link
href="/settings/upgrade-holiday-offer"
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
</>
)}
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+43 -89
View File
@@ -9,7 +9,8 @@ import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
import { CheckIcon, Users2Icon, CircleHelpIcon } from "lucide-react";
import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -19,11 +20,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
BadgeTooltip,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
@@ -31,7 +32,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push("/welcome?type=dataroom-trial");
router.push('/welcome?type=dataroom-trial');
};
return (
@@ -39,7 +40,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
onClick={handleStartTrial}
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
>
Start free data room trial
Start free Data Room trial
</span>
);
};
@@ -52,11 +53,7 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
<span>{feature.text}</span>
</div>
{feature.tooltip && (
@@ -79,11 +76,7 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("text-sm", baseClasses)}>
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
@@ -96,48 +89,37 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
);
};
// Segmented control component for Base/Plus/Premium selection
// Segmented control component for Base/Plus selection
const PlanSelector = ({
value,
onChange,
}: {
value: "base" | "plus" | "premium";
onChange: (value: "base" | "plus" | "premium") => void;
value: boolean;
onChange: (value: boolean) => void;
}) => {
return (
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
<div className="mt-1 flex w-1/2 rounded-lg border border-gray-200 p-1">
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "base"
!value
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("base")}
onClick={() => onChange(false)}
>
Base
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "plus"
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("plus")}
>
Plus
</button>
<button
className={cn(
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
value === "premium"
value
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
)}
onClick={() => onChange("premium")}
onClick={() => onChange(true)}
>
Premium
Plus
</button>
</div>
);
@@ -165,9 +147,7 @@ export function UpgradePlanModal({
const teamId = teamInfo?.currentTeam?.id;
const { plan: teamPlan, isCustomer, isOldAccount, isTrial } = usePlan();
const analytics = useAnalytics();
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
"base" | "plus" | "premium"
>("base");
const [showDataRoomsPlus, setShowDataRoomsPlus] = useState(false);
const plansToShow = useMemo(() => {
switch (clickedPlan) {
@@ -178,9 +158,7 @@ export function UpgradePlanModal({
case PlanEnum.DataRooms:
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
case PlanEnum.DataRoomsPlus:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
case PlanEnum.DataRoomsPremium:
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
default:
return [PlanEnum.Pro, PlanEnum.Business];
}
@@ -193,21 +171,10 @@ export function UpgradePlanModal({
trigger: trigger,
teamId,
});
// Track upgrade click for email scheduling (with trigger info for personalization)
if (teamId) {
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trigger }),
}).catch(() => {
// Silently fail - this is just for tracking
});
}
} else {
setDataRoomsPlanSelection("base");
setShowDataRoomsPlus(false);
}
}, [open, trigger, teamId]);
}, [open, trigger]);
const handleUpgradeClick = () => {
analytics.capture("Upgrade Button Clicked", {
@@ -248,26 +215,20 @@ export function UpgradePlanModal({
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
{plansToShow.map((planOption) => {
const isDataRoomsUpgrade = plansToShow.includes(PlanEnum.DataRooms);
// Determine which plan to show based on selection for Data Rooms
let effectivePlan = planOption;
let displayPlanName = planOption;
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
if (dataRoomsPlanSelection === "plus") {
effectivePlan = PlanEnum.DataRoomsPlus;
displayPlanName = PlanEnum.DataRoomsPlus;
} else if (dataRoomsPlanSelection === "premium") {
effectivePlan = PlanEnum.DataRoomsPremium;
displayPlanName = PlanEnum.DataRoomsPremium;
}
}
const planFeatures = getPlanFeatures(effectivePlan, {
const planFeatures = getPlanFeatures(planOption, {
period,
showDataRoomsPlus:
planOption === PlanEnum.DataRooms && showDataRoomsPlus,
});
// Get the effective plan name for display
const displayPlanName =
planOption === PlanEnum.DataRooms && showDataRoomsPlus
? PlanEnum.DataRoomsPlus
: planOption;
const isDataRoomsUpgrade = plansToShow.includes(PlanEnum.DataRooms);
return (
<div
key={displayPlanName}
@@ -283,7 +244,7 @@ export function UpgradePlanModal({
<div className="mb-4 border-b border-gray-200 pb-2">
<div className="flex items-center justify-between">
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
{displayPlanName}
Papermark {displayPlanName}
</h3>
</div>
<span
@@ -291,7 +252,7 @@ export function UpgradePlanModal({
"absolute right-2 top-2 rounded px-2 py-1 text-xs text-white",
planOption === PlanEnum.Business && "bg-[#fb7a00]",
displayPlanName === PlanEnum.DataRoomsPlus &&
"bg-gray-800 dark:bg-gray-200 dark:text-gray-900",
"bg-gray-900 dark:bg-gray-100 dark:text-gray-900",
)}
>
{planOption === PlanEnum.Business && "Most popular"}
@@ -309,7 +270,7 @@ export function UpgradePlanModal({
}
</span>
<span className="text-gray-500 dark:text-white/75">
/month{period === "yearly" && ", billed annually"}
/month
</span>
</div>
@@ -317,8 +278,8 @@ export function UpgradePlanModal({
isDataRoomsUpgrade &&
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
<PlanSelector
value={dataRoomsPlanSelection}
onChange={setDataRoomsPlanSelection}
value={showDataRoomsPlus}
onChange={setShowDataRoomsPlus}
/>
)}
@@ -352,11 +313,10 @@ export function UpgradePlanModal({
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
const priceId = getPriceIdFromPlan(
displayPlanName,
period,
isOld: isOldAccount,
});
);
setSelectedPlan(planOption);
if (isCustomer && teamPlan !== "free") {
@@ -413,23 +373,17 @@ export function UpgradePlanModal({
})}
</div>
<div className="flex flex-col items-center text-center text-sm text-muted-foreground">
All plans include unlimited visitors and page by page document
All plans include unlimited viewers and page by page document
analytics.
<div className="flex items-center gap-2">
<Link
href={`/settings/upgrade${
clickedPlan === PlanEnum.Pro
? "?view=documents"
: clickedPlan === PlanEnum.Business
? "?view=business-datarooms"
: ""
}`}
href="/settings/upgrade"
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
{trigger === "sidebar_datarooms" &&
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
@@ -1,159 +0,0 @@
"use client";
import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import Cookies from "js-cookie";
import { CheckIcon, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { usePlan } from "@/lib/swr/use-billing";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { UpgradePlanModal } from "./upgrade-plan-modal";
interface YearlyUpgradeBannerProps {
setShowBanner: Dispatch<SetStateAction<boolean | null>>;
}
export default function YearlyUpgradeBanner({
setShowBanner,
}: YearlyUpgradeBannerProps) {
const router = useRouter();
const { plan: teamPlan } = usePlan();
const handleHideBanner = () => {
setShowBanner(false);
Cookies.set("hideYearlyUpgradeBanner", "yearly-upgrade-banner", {
expires: 7,
});
};
// Get next higher plan
const getNextPlan = () => {
if (teamPlan === "pro") return PlanEnum.Business;
if (teamPlan === "business") return PlanEnum.DataRooms;
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
return null;
};
const nextPlan = getNextPlan();
if (!nextPlan) return null; // Don't show banner if no next plan
return (
<AnimatePresence>
<motion.aside
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className={cn(
"fixed right-0 top-1/2 z-50 w-80 -translate-y-1/2 rounded-l-lg border-b border-l border-t border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900",
)}
>
<button
type="button"
onClick={handleHideBanner}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<div className="mb-4 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-[#fb7a00]" />
<span className="text-lg font-bold text-black dark:text-white">
UPGRADE YOUR PLAN
</span>
</div>
<div className="mb-2">
<p className="text-xs text-gray-500 dark:text-gray-400">
Save up to 35% with yearly plans
</p>
</div>
{/* Next Plan Card - Orange */}
{(() => {
const planData = PLANS.find((p) => p.name === nextPlan);
if (!planData) return null;
const yearlyPrice = planData.price.yearly.amount;
const monthlyPrice = planData.price.monthly.amount;
const monthlyForYear = monthlyPrice * 12;
const yearlyForYear = yearlyPrice * 12;
const savings = monthlyForYear - yearlyForYear;
const planFeatures = getPlanFeatures(nextPlan, {
period: "yearly",
maxFeatures: 4,
});
return (
<div className="rounded-lg border-2 border-[#fb7a00] bg-orange-50 p-3 dark:bg-orange-900/20">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-bold text-[#fb7a00]">
RECOMMENDED
</span>
{savings > 0 && (
<span className="text-xs font-medium text-[#fb7a00]">
Save {savings}/year
</span>
)}
</div>
<p className="mb-1 text-sm font-semibold text-gray-900 dark:text-white">
{nextPlan} plan
</p>
<div className="mb-3 flex items-baseline gap-2">
<span className="text-2xl font-semibold text-gray-900 dark:text-white">
{yearlyPrice}
</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
/mo, billed annually
</span>
</div>
{/* Features */}
<ul className="mb-3 space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
{planFeatures.features.slice(0, 4).map((feature, i) => (
<li key={i} className="flex items-center gap-2">
<CheckIcon className="h-3.5 w-3.5 flex-shrink-0 text-[#fb7a00]" />
<span className="leading-tight">{feature.text}</span>
</li>
))}
</ul>
<UpgradePlanModal
clickedPlan={nextPlan}
trigger="yearly_upgrade_banner"
>
<Button
type="button"
size="sm"
className="w-full bg-[#fb7a00] py-3 leading-tight text-white hover:bg-[#fb7a00]/90"
>
Upgrade to {nextPlan} Yearly
</Button>
</UpgradePlanModal>
</div>
);
})()}
<p
onClick={() => router.push("/settings/upgrade")}
className="mt-4 cursor-pointer text-center text-xs text-gray-500 underline hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
>
Compare all plans
</p>
</motion.aside>
</AnimatePresence>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { AssistantStatus, type Message } from "ai/react";
import Textarea from "react-textarea-autosize";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useEnterSubmit } from "@/lib/utils/use-enter-submit";
import ArrowUp from "../shared/icons/arrow-up";
export function ChatInput({
status,
error,
input,
setInput,
handleInputChange,
submitMessage,
messages,
}: {
status: AssistantStatus;
error: unknown;
input: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
submitMessage: (e: any) => Promise<void>;
messages: Message[];
}) {
const { formRef, onKeyDown } = useEnterSubmit();
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (status === "awaiting_message") {
inputRef.current?.focus();
}
}, [status]);
return (
<div className="relative inset-x-0 bottom-0">
<div className="mx-auto sm:max-w-3xl sm:px-4">
<div className="space-y-4 bg-background px-4 py-4 md:py-4">
<form onSubmit={submitMessage} ref={formRef}>
<div className="relative flex max-h-60 w-full flex-col overflow-hidden rounded-xl bg-background pr-8 ring-1 ring-muted-foreground/50 focus-within:ring-1 focus-within:ring-foreground sm:pr-12">
<Textarea
ref={inputRef}
tabIndex={0}
rows={1}
onKeyDown={onKeyDown}
disabled={status !== "awaiting_message"}
className="min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-[1.3rem] focus:ring-0 sm:text-sm"
value={input}
placeholder="Message Papermark Assistant..."
onChange={handleInputChange}
spellCheck={false}
/>
<div className="absolute bottom-3 right-3">
<Button
type="submit"
disabled={status === "in_progress" || input === ""}
title="Send message"
className="h-10 w-10 rounded-md p-1 md:p-2"
>
<ArrowUp className="h-full w-full" />
<span className="sr-only">Send message</span>
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
);
}
function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
fill="currentColor"
className={cn("h-4 w-4", className)}
{...props}
>
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
</svg>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { type Message } from "ai";
import { Separator } from "@/components/ui/separator";
import Skeleton from "../Skeleton";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import { ChatMessage } from "./chat-message";
export interface ChatList {
messages: Message[];
status: "in_progress" | "awaiting_message";
}
export function ChatList({ messages, status }: ChatList) {
if (!messages.length) {
return null;
}
return (
<div className="relative mx-auto max-w-2xl px-4">
{messages.map((message, index) => (
<div key={index}>
<ChatMessage message={message} />
{index < messages.length - 1 && (
<Separator className="my-4 bg-background" />
)}
</div>
))}
{status === "in_progress" && (
<>
<Separator className="my-4 bg-background" />
<div
key={"loading-message"}
className="group relative mb-4 ml-5 flex items-start whitespace-pre-wrap"
>
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow">
<PapermarkSparkle />
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="select-none font-semibold">
Papermark Assistant
</div>
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { type Message } from "ai";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
import Check from "../shared/icons/check";
import Copy from "../shared/icons/copy";
interface ChatMessageActionsProps extends React.ComponentProps<"div"> {
message: Message;
}
export function ChatMessageActions({
message,
className,
...props
}: ChatMessageActionsProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const onCopy = () => {
if (isCopied) return;
copyToClipboard(message.content);
};
return (
<div
className={cn(
"flex items-center justify-end md:absolute md:-top-2 md:right-0 md:hidden",
className,
)}
{...props}
>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? <Check /> : <Copy />}
<span className="sr-only">Copy message</span>
</Button>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { type Message } from "ai";
import { cn } from "@/lib/utils";
import AlertCircle from "../shared/icons/alert-circle";
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
import UserRound from "../shared/icons/user-round";
import { ChatMessageActions } from "./chat-message-actions";
// map role to icon and name
const mapMessageRole = {
user: { icon: <UserRound />, name: "You" },
system: { icon: <AlertCircle />, name: "System" },
assistant: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
function: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
data: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
tool: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
};
export interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message, ...props }: ChatMessageProps) {
return (
<div
key={message.id}
className={cn(
"group relative mb-4 flex items-start whitespace-pre-wrap md:ml-5",
)}
{...props}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow",
message.role === "user"
? "bg-background"
: message.role === "system"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
)}
>
{mapMessageRole[message.role].icon}
</div>
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
<div className="group relative flex w-[calc(100%-50px)] flex-col">
<div className="select-none font-semibold">
{mapMessageRole[message.role].name}
</div>
<div className="prose break-words font-light dark:prose-invert prose-p:leading-relaxed prose-pre:p-0">
<p className="mb-2 last:mb-0">{message.content}</p>
</div>
</div>
<ChatMessageActions className="group-hover:flex" message={message} />
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import { useInView } from "react-intersection-observer";
import { useAtBottom } from "@/lib/utils/use-at-bottom";
interface ChatScrollAnchorProps {
trackVisibility?: boolean;
}
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
const isAtBottom = useAtBottom();
const { ref, entry, inView } = useInView({
trackVisibility,
delay: 100,
rootMargin: "0px 0px -100px 0px",
});
React.useEffect(() => {
if (isAtBottom && trackVisibility && !inView) {
entry?.target.scrollIntoView({
block: "start",
});
}
}, [inView, entry, isAtBottom, trackVisibility]);
return <div ref={ref} className="h-px w-full" />;
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState } from "react";
import {
type Message,
experimental_useAssistant as useAssistant,
} from "ai/react";
import { nanoid } from "nanoid";
import { BasePlan } from "@/lib/swr/use-billing";
import { cn } from "@/lib/utils";
import { ChatInput } from "./chat-input";
import { ChatList } from "./chat-list";
import { ChatScrollAnchor } from "./chat-scroll-anchor";
import { EmptyScreen } from "./empty-screen";
export interface ChatProps extends React.ComponentProps<"div"> {
initialMessages: Message[];
threadId?: string;
firstPage?: string;
isPublic?: boolean;
userId?: string;
plan?: BasePlan | null;
}
export function Chat({
initialMessages,
threadId,
firstPage,
className,
isPublic,
userId,
plan,
}: ChatProps) {
const {
status,
messages: hookMessages,
input: hookInput,
submitMessage,
handleInputChange,
error,
} = useAssistant({
api: "/api/assistants/chat",
threadId: threadId,
body: {
isPublic: isPublic,
userId: userId,
plan: plan,
},
});
const [combinedMessages, setCombinedMessages] = useState<Message[]>([]);
useEffect(() => {
if (error instanceof Error) {
let content: string = "";
if (isPublic) {
content =
"You have reached your request limit for the day. Sign up for a free account to continue using Papermark Assistant.";
}
if (userId && plan !== "pro") {
content =
"You have reached your request limit for the day. Upgrade to a paid account to continue using Papermark Assistant.";
}
const message: Message = {
role: "system",
content: content,
id: nanoid(),
};
setCombinedMessages((prev) => [...prev, message]);
}
}, [error]);
useEffect(() => {
// Concatenate existing messages with messages from the hook
// and reverse the order so that the newest messages are at the bottom:
const reversedMessages = [...initialMessages].reverse();
setCombinedMessages([...reversedMessages, ...hookMessages]);
}, [initialMessages, hookMessages]);
let isLoading;
if (status === "in_progress") {
isLoading = true;
} else if (status === "awaiting_message") {
isLoading = true;
}
const [_, setInput] = useState<string>(hookInput);
return (
<>
<div
className={cn(
"relative h-[calc(100vh-96px)] overflow-y-auto pb-[20px] pt-24",
className,
)}
>
{combinedMessages.length ? (
<>
<ChatList messages={combinedMessages} status={status} />
{!isPublic ? (
<ChatScrollAnchor trackVisibility={isLoading} />
) : null}
</>
) : (
<EmptyScreen
firstPage={firstPage}
handleInputChange={handleInputChange}
setInput={setInput}
/>
)}
</div>
<ChatInput
status={status}
error={error}
messages={combinedMessages}
input={hookInput}
setInput={setInput}
submitMessage={submitMessage}
handleInputChange={handleInputChange}
/>
</>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import Sparkle from "../shared/icons/sparkle";
const exampleMessages = [
{
heading: "Summarize the document",
message: "Summarize the document in a few sentences and bullet points.",
},
{
heading: "Tell me about the team",
message:
"Tell me more about the team behind the company. If there's no team page in the document, then let me know.",
},
{
heading: "Ask a question",
message: "What does the company do?",
},
{
heading: "What is this document about?",
message: "Please tell me in one sentence what this document is about.",
},
];
export function EmptyScreen({
firstPage,
setInput,
handleInputChange,
}: {
firstPage?: string;
setInput: (input: string) => void;
handleInputChange: (e: any) => void;
}) {
const manageInput = (message: string) => {
setInput(message);
handleInputChange(message);
};
return (
<>
<div className="mx-auto w-1/2 md:w-1/3">
<div className="flex items-center justify-center">
{firstPage ? (
firstPage.includes("cloudfront.net") ? (
<img
className="rounded-md object-contain ring-1 ring-gray-700"
src={firstPage}
alt={`Page 1`}
fetchPriority="high"
/>
) : (
<Image
src={firstPage}
width={768}
height={100}
className="rounded-md object-contain ring-1 ring-gray-700"
alt="First page of the document"
/>
)
) : (
<div className="flex w-full items-center justify-center rounded-md">
<p className="text-center text-2xl">
Chat with{" "}
<span className="text-2xl font-bold tracking-tighter ">
Papermark
</span>
&apos;s pitchdeck
</p>
</div>
)}
</div>
<div className="mt-4 flex justify-center text-lg">
What would you like to know?
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-2xl px-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{exampleMessages.map((message, index) => (
<Button
key={index}
variant="outline"
className="h-auto p-4 text-sm"
onClick={() => {
return handleInputChange({
target: { value: message.message },
});
}}
>
{message.heading}
</Button>
))}
</div>
</div>
</>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/shared/conversation-message";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/conversation-message";
export function ConversationListItem(props: any) {
return <ConversationListItemEE {...props} />;
@@ -3,7 +3,8 @@ import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import { Button } from "@/components/ui/button";
import { ButtonTooltip } from "@/components/ui/tooltip";
export default function DownloadDataroomButton({
teamId,
@@ -56,13 +57,16 @@ export default function DownloadDataroomButton({
}
};
return (
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={downloadDataroom}
variant="outline"
size="sm"
loading={isLoading}
/>
<ButtonTooltip content="Download Dataroom" sideOffset={4}>
<Button
onClick={downloadDataroom}
variant="outline"
className=""
size="sm"
loading={isLoading}
>
<DownloadIcon />
</Button>
</ButtonTooltip>
);
}
@@ -25,7 +25,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import {
Select,
SelectContent,
@@ -136,13 +135,10 @@ export default function GenerateIndexDialog({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<FileSlidersIcon className="h-4 w-4" />}
text="Generate Index"
variant="outline"
size="sm"
disabled={disabled}
/>
<Button variant="outline" size="sm" disabled={disabled}>
<FileSlidersIcon />
Generate Index File
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -1,165 +0,0 @@
import { useState } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import { ListOrderedIcon } from "lucide-react";
import { toast } from "sonner";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { usePlan } from "@/lib/swr/use-billing";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
interface RebuildIndexButtonProps {
teamId: string;
dataroomId: string;
disabled?: boolean;
}
export default function RebuildIndexButton({
teamId,
dataroomId,
disabled = false,
}: RebuildIndexButtonProps) {
const { isFeatureEnabled } = useFeatureFlags();
const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const hasDataroomsPlan = isDatarooms || isDataroomsPlus || isTrial;
const hasDataroomsPlusPlan = isDataroomsPlus;
// Show button if: feature flag is enabled OR user has datarooms plan or higher
const shouldShowButton = isDataroomIndexEnabled || hasDataroomsPlan;
// Allow usage if: feature flag is enabled OR user has datarooms-plus plan
const canUseFeature = isDataroomIndexEnabled || hasDataroomsPlusPlan;
// Don't render if conditions aren't met
if (!shouldShowButton) {
return null;
}
const handleRebuildIndex = async () => {
if (!canUseFeature) {
toast.error("Upgrade to Data Rooms Plus plan to use this feature.");
return;
}
try {
setIsLoading(true);
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/calculate-indexes`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to rebuild indexes");
}
const result = await response.json();
toast.success(
`Hierarchical indexes rebuilt successfully! Updated ${result.totalUpdated} items (${result.foldersUpdated} folders, ${result.documentsUpdated} documents).`,
);
setIsOpen(false);
// Trigger a page refresh to show updated indexes
window.location.reload();
} catch (error) {
console.error("Error rebuilding indexes:", error);
toast.error(
error instanceof Error ? error.message : "Failed to rebuild indexes",
);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<ListOrderedIcon className="h-4 w-4" />}
text="Rebuild Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
Rebuild Hierarchical Index
</DialogTitle>
<DialogDescription>
Recalculate the hierarchical numbering based on the dataroom
items&apos; current order.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="rounded-lg bg-muted p-4">
<div className="flex items-start gap-3">
<div className="text-sm text-muted-foreground">
<p className="mb-1 font-medium">What this does:</p>
<ul className="list-inside list-disc space-y-1">
<li>
Analyzes the current folder structure and document order
</li>
<li>
Assigns hierarchical numbers (1, 1.1, 1.1.1, 2, 2.1, etc.)
</li>
<li>
Updates the display to show these numbers alongside names
</li>
<li>Maintains the existing order and hierarchy</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
{canUseFeature ? (
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button onClick={handleRebuildIndex} loading={isLoading}>
<ListOrderedIcon className="h-4 w-4" />
Rebuild Index
</Button>
</>
) : (
<UpgradePlanModal
clickedPlan={PlanEnum.DataRoomsPlus}
trigger="datarooms_rebuild_index_button"
highlightItem={["indexing"]}
>
<Button>Upgrade to rebuild index</Button>
</UpgradePlanModal>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -60,7 +60,9 @@ function RemoveDataroomItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to remove dataroom document");
throw new Error(
`Failed to remove dataroom document ${documentId}: ${error.message}`,
);
}
analytics.capture("Dataroom Document Removed", {
team: teamInfo?.currentTeam?.id,
@@ -76,7 +78,9 @@ function RemoveDataroomItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to remove dataroom folder");
throw new Error(
`Failed to remove folderId folder ${folderId}: ${error.message}`,
);
}
analytics.capture("Dataroom folder Removed", {
team: teamInfo?.currentTeam?.id,
+42 -749
View File
@@ -4,56 +4,25 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
import {
Brain,
BriefcaseIcon,
BuildingIcon,
FileTextIcon,
FolderKanbanIcon,
FolderIcon,
HomeIcon,
LineChartIcon,
RocketIcon,
ShoppingCartIcon,
Sparkles,
TrendingUpIcon,
XIcon,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
@@ -70,219 +39,21 @@ export function AddDataroomModal({
const [dataroomName, setDataroomName] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(openModal);
const [activeTab, setActiveTab] = useState<string>("create");
const [dataroomType, setDataroomType] = useState<string>("");
const [aiDescription, setAiDescription] = useState<string>("");
const [aiGenerating, setAiGenerating] = useState<boolean>(false);
const [generatedFolders, setGeneratedFolders] = useState<any[] | null>(null);
const [showPreview, setShowPreview] = useState<boolean>(false);
const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(
new Set(),
);
const [editingFolderPath, setEditingFolderPath] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState<string>("");
const teamInfo = useTeam();
const { isFree, isPro } = usePlan();
const analytics = useAnalytics();
const useTemplate = activeTab === "generate";
const useAI = activeTab === "ai";
const dataroomSchema = z.object({
name: z.string().trim().min(3, {
message: "Please provide a dataroom name with at least 3 characters.",
}),
type: z.string().optional(),
});
const dataroomSchemaWithType = z.object({
type: z.enum(
[
"startup-fundraising",
"raising-first-fund",
"ma-acquisition",
"series-a-plus",
"real-estate-transaction",
"fund-management",
"portfolio-management",
"project-management",
"sales-dataroom",
],
{
errorMap: () => ({ message: "Please select a dataroom type." }),
},
),
});
const TEMPLATES = [
{
id: "startup-fundraising",
name: "Startup Fundraising",
icon: RocketIcon,
},
{
id: "series-a-plus",
name: "Series A+",
icon: TrendingUpIcon,
},
{
id: "raising-first-fund",
name: "Raising a Fund",
icon: LineChartIcon,
},
{
id: "ma-acquisition",
name: "M&A / Acquisition",
icon: BriefcaseIcon,
},
{
id: "sales-dataroom",
name: "Sales Data Room",
icon: ShoppingCartIcon,
},
{
id: "real-estate-transaction",
name: "Real Estate",
icon: HomeIcon,
},
{
id: "fund-management",
name: "Fund Management",
icon: BuildingIcon,
},
{
id: "portfolio-management",
name: "Portfolio Management",
icon: FolderKanbanIcon,
},
{
id: "project-management",
name: "Project Management",
icon: FileTextIcon,
},
];
const handleGenerateFolders = async () => {
if (!aiDescription.trim()) {
return toast.error("Please describe what kind of dataroom you want to create.");
}
setAiGenerating(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai-structure`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
description: aiDescription.trim(),
}),
},
);
if (!response.ok) {
const { message } = await response.json();
setAiGenerating(false);
toast.error(message || "Failed to generate folder structure");
return;
}
const { name, folders } = await response.json();
setGeneratedFolders(folders);
// Set the generated name as default, but allow user to edit it
if (name) {
setDataroomName(name);
}
// Initialize all folders as selected
initializeFolderSelection(folders);
setShowPreview(true);
setAiGenerating(false);
} catch (error) {
setAiGenerating(false);
toast.error("Error generating folder structure. Please try again.");
}
};
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
// For AI generation, if we're in preview mode, create the dataroom
if (useAI && showPreview && generatedFolders) {
// Validate dataroom name
if (!dataroomName.trim()) {
return toast.error("Please provide a dataroom name.");
}
if (dataroomName.trim().length < 3) {
return toast.error(
"Please provide a dataroom name with at least 3 characters.",
);
}
// Filter folders based on selection
const filteredFolders = filterSelectedFolders(generatedFolders);
if (filteredFolders.length === 0) {
return toast.error("Please select at least one folder to include.");
}
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: dataroomName.trim(),
folders: filteredFolders,
}),
},
);
if (!response.ok) {
const { message } = await response.json();
setLoading(false);
toast.error(message);
return;
}
const { dataroom } = await response.json();
analytics.capture("Dataroom Generated with AI", {
dataroomName: dataroomName.trim(),
});
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
toast.success("Dataroom successfully generated! 🎉");
router.push(`/datarooms/${dataroom.id}/documents`);
} catch (error) {
setLoading(false);
toast.error("Error creating dataroom. Please try again.");
return;
} finally {
setLoading(false);
setOpen(false);
if (openModal && setOpenModal) setOpenModal(false);
}
return;
}
// Validate based on whether template is enabled
const schema = useTemplate ? dataroomSchemaWithType : dataroomSchema;
const validation = schema.safeParse({
name: useTemplate ? undefined : dataroomName,
type: useTemplate ? dataroomType : undefined,
});
const validation = dataroomSchema.safeParse({ name: dataroomName });
if (!validation.success) {
return toast.error(validation.error.errors[0].message);
}
@@ -290,25 +61,18 @@ export function AddDataroomModal({
setLoading(true);
try {
// Use different endpoint based on whether template is enabled
const endpoint = useTemplate
? `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate`
: `/api/teams/${teamInfo?.currentTeam?.id}/datarooms`;
const body = useTemplate
? {
type: dataroomType,
// Name will be taken from template
}
: { name: dataroomName.trim() };
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: dataroomName.trim(),
}),
},
body: JSON.stringify(body),
});
);
if (!response.ok) {
const { message } = await response.json();
@@ -319,21 +83,10 @@ export function AddDataroomModal({
const { dataroom } = await response.json();
analytics.capture(
useTemplate ? "Dataroom Generated" : "Dataroom Created",
{
dataroomName: dataroomName,
...(useTemplate && { dataroomType: dataroomType }),
},
);
analytics.capture("Dataroom Created", { dataroomName: dataroomName });
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
toast.success(
useTemplate
? "Dataroom successfully created! 🎉"
: "Dataroom successfully created! 🎉",
);
toast.success("Dataroom successfully created! 🎉");
router.push(`/datarooms/${dataroom.id}/documents`);
} catch (error) {
setLoading(false);
@@ -363,498 +116,38 @@ export function AddDataroomModal({
const onOpenChange = (open: boolean) => {
if (!open) {
setOpen(false);
setDataroomName("");
setActiveTab("create");
setDataroomType("");
setAiDescription("");
setGeneratedFolders(null);
setShowPreview(false);
setSelectedFolderPaths(new Set());
setEditingFolderPath(null);
setEditingFolderName("");
} else {
setOpen(true);
}
if (openModal && setOpenModal) setOpenModal(false);
};
// Generate a unique path for each folder
const generateFolderPath = (folder: any, parentPath: string = ""): string => {
const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;
return currentPath;
};
// Update folder name in the folder structure
const updateFolderName = (
folders: any[],
targetPath: string,
newName: string,
currentPath: string = "",
): any[] => {
return folders.map((folder) => {
const folderPath = generateFolderPath(folder, currentPath);
if (folderPath === targetPath) {
// Update this folder's name
return { ...folder, name: newName };
}
// Recursively update subfolders
if (folder.subfolders && folder.subfolders.length > 0) {
return {
...folder,
subfolders: updateFolderName(
folder.subfolders,
targetPath,
newName,
folderPath,
),
};
}
return folder;
});
};
// Handle folder name edit
const handleFolderNameEdit = (path: string, currentName: string) => {
setEditingFolderPath(path);
setEditingFolderName(currentName);
};
// Save edited folder name
const saveFolderName = () => {
if (!editingFolderPath || !generatedFolders) return;
if (editingFolderName.trim()) {
const updatedFolders = updateFolderName(
generatedFolders,
editingFolderPath,
editingFolderName.trim(),
);
setGeneratedFolders(updatedFolders);
// Update selected paths if needed (recalculate paths)
initializeFolderSelection(updatedFolders);
}
setEditingFolderPath(null);
setEditingFolderName("");
};
// Cancel editing
const cancelFolderNameEdit = () => {
setEditingFolderPath(null);
setEditingFolderName("");
};
// Toggle folder selection
const toggleFolderSelection = (path: string, folder: any) => {
const newSelected = new Set(selectedFolderPaths);
if (newSelected.has(path)) {
// If unchecking, remove this folder and all its subfolders
newSelected.delete(path);
const removeSubfolders = (f: any, currentPath: string) => {
if (f.subfolders) {
f.subfolders.forEach((sub: any) => {
const subPath = generateFolderPath(sub, currentPath);
newSelected.delete(subPath);
removeSubfolders(sub, subPath);
});
}
};
removeSubfolders(folder, path);
} else {
// If checking, add this folder
newSelected.add(path);
}
setSelectedFolderPaths(newSelected);
};
// Filter folders based on selection
const filterSelectedFolders = (folders: any[], parentPath: string = ""): any[] => {
return folders
.map((folder) => {
const currentPath = generateFolderPath(folder, parentPath);
const isSelected = selectedFolderPaths.has(currentPath);
if (!isSelected) {
return null;
}
const filteredSubfolders = folder.subfolders
? filterSelectedFolders(folder.subfolders, currentPath)
: undefined;
return {
...folder,
subfolders: filteredSubfolders && filteredSubfolders.length > 0
? filteredSubfolders
: undefined,
};
})
.filter((folder) => folder !== null);
};
// Initialize all folders as selected when preview is shown
const initializeFolderSelection = (folders: any[], parentPath: string = "") => {
const paths = new Set<string>();
const collectPaths = (f: any[], currentPath: string = "") => {
f.forEach((folder) => {
const folderPath = generateFolderPath(folder, currentPath);
paths.add(folderPath);
if (folder.subfolders) {
collectPaths(folder.subfolders, folderPath);
}
});
};
collectPaths(folders);
setSelectedFolderPaths(paths);
};
const renderFolderPreview = (
folders: any[],
indent = 0,
parentPath: string = "",
) => {
return (
<div className="space-y-1">
{folders.map((folder, index) => {
const currentPath = generateFolderPath(folder, parentPath);
const isSelected = selectedFolderPaths.has(currentPath);
return (
<div key={`${parentPath}-${index}`} className="pl-4">
<div className="flex items-center gap-2 py-1">
<div
className="h-4 w-4"
style={{ marginLeft: `${indent * 16}px` }}
/>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleFolderSelection(currentPath, folder)}
id={`folder-${currentPath}`}
/>
<label
htmlFor={`folder-${currentPath}`}
className="flex items-center gap-2 cursor-pointer flex-1"
>
<FolderIcon className="h-4 w-4 text-gray-500" />
{editingFolderPath === currentPath ? (
<Input
value={editingFolderName}
onChange={(e) => setEditingFolderName(e.target.value)}
onBlur={saveFolderName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveFolderName();
} else if (e.key === "Escape") {
e.preventDefault();
cancelFolderNameEdit();
}
}}
className="h-6 text-sm px-2 py-1"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-1 py-0.5 rounded cursor-text"
onClick={(e) => {
e.stopPropagation();
handleFolderNameEdit(currentPath, folder.name);
}}
title="Click to edit folder name"
>
{folder.name}
</span>
)}
</label>
</div>
{folder.subfolders && folder.subfolders.length > 0 && (
<div className="ml-4">
{renderFolderPreview(folder.subfolders, indent + 1, currentPath)}
</div>
)}
</div>
);
})}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="border-none bg-transparent text-foreground shadow-none sm:max-w-[575px] [&>button]:hidden">
<DialogTitle className="sr-only">Create Dataroom</DialogTitle>
<DialogDescription className="sr-only">
Create a new dataroom or generate one with pre-configured folders
</DialogDescription>
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value);
// Reset AI-related state when switching tabs
if (value !== "ai") {
setAiDescription("");
setGeneratedFolders(null);
setShowPreview(false);
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="create">Create from scratch</TabsTrigger>
<TabsTrigger value="generate">Create from template</TabsTrigger>
<TabsTrigger
value="ai"
className={`flex items-center gap-2 ${
activeTab === "ai"
? "text-orange-500 data-[state=active]:text-orange-500"
: ""
}`}
>
<Sparkles
className={`h-4 w-4 ${
activeTab === "ai" ? "text-orange-500" : "text-current"
}`}
/>
Generate with AI
</TabsTrigger>
</TabsList>
<TabsContent value="create">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Create dataroom</CardTitle>
<CardDescription>
Start creating a dataroom with a name.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-1">
<Label htmlFor="dataroom-name-create">
Dataroom Name{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Input
id="dataroom-name-create"
placeholder="ACME Acquisition"
value={dataroomName}
onChange={(e) => setDataroomName(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" loading={loading}>
Add new dataroom
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="generate">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Create dataroom</CardTitle>
<CardDescription>
Create a dataroom with pre-configured folder structure.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-2">
<Label>
Select Template{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<div className="grid grid-cols-3 divide-x divide-y divide-border overflow-hidden rounded-md border ">
{TEMPLATES.map((template) => {
const Icon = template.icon;
const isSelected = dataroomType === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => setDataroomType(template.id)}
className={`relative flex min-h-[120px] flex-col items-center justify-center space-y-3 overflow-hidden p-4 transition-colors ${
isSelected
? "bg-gray-200 dark:bg-gray-800"
: "hover:bg-gray-100 hover:dark:bg-gray-800"
}`}
>
<Icon className="pointer-events-none h-auto w-10 text-foreground" />
<p className="text-sm">{template.name}</p>
</button>
);
})}
</div>
</div>
<Button
type="submit"
className="w-full"
loading={loading}
disabled={!dataroomType}
>
Create new dataroom
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai">
<Card className="relative outline-none focus:outline-none">
<button
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
<CardHeader className="space-y-3">
<CardTitle>Generate dataroom with AI</CardTitle>
<CardDescription>
AI will create a unique and proven data room structure for your use case.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!showPreview ? (
<>
<div className="space-y-1">
<Label htmlFor="ai-description">
Describe your dataroom in details{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Textarea
id="ai-description"
placeholder="A data room for a Series B fundraising round for a AI startup called 'Acme AI'. Create advanced data room with IP information and financials."
value={aiDescription}
onChange={(e) => setAiDescription(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
<Button
type="button"
className="w-full"
loading={aiGenerating}
onClick={handleGenerateFolders}
disabled={!aiDescription.trim() || aiGenerating}
>
Generate data room structure
</Button>
</>
) : (
<form
onSubmit={handleSubmit}
className="flex flex-col space-y-4 outline-none"
>
<div className="space-y-1">
<Label htmlFor="dataroom-name-ai">
Dataroom Name{" "}
<span className="text-black dark:text-white">*</span>
</Label>
<Input
id="dataroom-name-ai"
placeholder="AI Generated Data Room"
value={dataroomName}
onChange={(e) => setDataroomName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Generated Folder Structure{" "}
<span className="text-xs text-muted-foreground font-normal">
(Select folders to include)
</span>
</Label>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={
generatedFolders &&
selectedFolderPaths.size > 0 &&
generatedFolders.every((folder) => {
const path = generateFolderPath(folder);
return selectedFolderPaths.has(path);
})
? true
: false
}
onCheckedChange={(checked) => {
if (checked && generatedFolders) {
initializeFolderSelection(generatedFolders);
} else {
setSelectedFolderPaths(new Set());
}
}}
id="select-all"
/>
<label
htmlFor="select-all"
className="cursor-pointer"
>
Select/Deselect All
</label>
</div>
</div>
<div className="max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
{generatedFolders && renderFolderPreview(generatedFolders)}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
setShowPreview(false);
setGeneratedFolders(null);
}}
>
Back
</Button>
<Button
type="submit"
className="flex-1"
loading={loading}
>
Create Dataroom
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>Create dataroom</DialogTitle>
<DialogDescription>
Start creating a dataroom with a name.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Label htmlFor="dataroom-name" className="opacity-80">
Dataroom Name
</Label>
<Input
id="dataroom-name"
placeholder="ACME Aquisition"
className="mb-4 mt-1 w-full"
onChange={(e) => setDataroomName(e.target.value)}
/>
<DialogFooter>
<Button type="submit" className="h-9 w-full" loading={loading}>
Add new dataroom
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
+34 -89
View File
@@ -7,10 +7,6 @@ import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import {
Breadcrumb,
@@ -29,70 +25,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { TruncatedBreadcrumbLink } from "../layouts/breadcrumb";
const BreadcrumbFolderItem = ({
folder,
dataroomId,
isLast,
}: {
folder: any;
dataroomId: string;
isLast: boolean;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
if (isLast) {
return (
<BreadcrumbPage
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbPage>
);
}
return (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</BreadcrumbLink>
);
};
const BreadcrumbDropdownItem = ({
folder,
dataroomId,
}: {
folder: any;
dataroomId: string;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
return (
<DropdownMenuItem>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</DropdownMenuItem>
);
};
function BreadcrumbComponentBase({
name,
dataroomId,
@@ -137,41 +69,54 @@ function BreadcrumbComponentBase({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{folders.slice(0, -2).map((folder, index) => (
<BreadcrumbDropdownItem
key={index}
folder={folder}
dataroomId={dataroomId}
/>
<DropdownMenuItem key={index}>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
>
{folder.name}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folders[folders.length - 2]}
dataroomId={dataroomId}
isLast={false}
/>
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
className="max-w-[200px] truncate"
>
{folders[folders.length - 2].name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folders[folders.length - 1]}
dataroomId={dataroomId}
isLast={true}
/>
<BreadcrumbPage className="max-w-[200px] truncate">
{folders[folders.length - 1].name}
</BreadcrumbPage>
</BreadcrumbItem>
</>
) : (
folders?.map((folder, index) => (
<React.Fragment key={index}>
<BreadcrumbItem>
<BreadcrumbFolderItem
folder={folder}
dataroomId={dataroomId}
isLast={index === folders.length - 1}
/>
{index === folders.length - 1 ? (
<BreadcrumbPage className="max-w-[200px] truncate">
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
>
{folder.name}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < folders.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
@@ -196,4 +141,4 @@ const BreadcrumbComponent = () => {
return MemoizedBreadcrumbComponent;
};
export { BreadcrumbComponent };
export { BreadcrumbComponent };
-233
View File
@@ -1,233 +0,0 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/router";
import { formatDistanceToNow } from "date-fns";
import { TagColorProps } from "@/lib/types";
import TagBadge from "@/components/links/link-sheet/tags/tag-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DataroomTag = {
tag: {
id: string;
name: string;
color: string;
description: string | null;
};
};
type DataroomWithDetails = {
id: string;
name: string;
internalName?: string | null;
_count: {
documents: number;
views: number;
};
activeLinkCount: number;
lastViewedAt: Date | null;
createdAt: Date;
tags?: DataroomTag[];
};
interface DataroomCardProps {
dataroom: DataroomWithDetails;
}
export default function DataroomCard({ dataroom }: DataroomCardProps) {
const router = useRouter();
const isActive = dataroom.activeLinkCount > 0;
const activeLinkCount = dataroom.activeLinkCount;
const lastViewedAt = dataroom.lastViewedAt;
const hasDocuments = dataroom._count.documents > 0;
const handleButtonClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!hasDocuments) {
// If no documents, go to documents tab
router.push(`/datarooms/${dataroom.id}/documents`);
} else if (!isActive) {
// If has documents but inactive, go to permissions/share
router.push(`/datarooms/${dataroom.id}/permissions`);
}
};
return (
<Card className="group relative overflow-hidden duration-500 hover:border-primary/50">
<Link href={`/datarooms/${dataroom.id}/documents`}>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate text-lg">
{dataroom.internalName || dataroom.name}
</CardTitle>
{dataroom.internalName && (
<p className="truncate text-sm text-muted-foreground mt-0.5">
{dataroom.name}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center gap-1.5"
onClick={(e) => e.preventDefault()}
>
<div
className={`h-2 w-2 rounded-full ${
isActive ? "bg-green-500" : "bg-gray-400"
}`}
/>
<span
className={`text-xs ${
isActive ? "text-green-600" : "text-gray-500"
}`}
>
{isActive ? "Active" : "Inactive"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isActive
? "Dataroom has active links"
: "No active links"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Tags */}
{dataroom.tags && dataroom.tags.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
onClick={(e) => e.preventDefault()}
>
{dataroom.tags.slice(0, 3).map((tagItem) => (
<TagBadge
key={tagItem.tag.id}
name={tagItem.tag.name}
color={tagItem.tag.color as TagColorProps}
withIcon
/>
))}
{dataroom.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{dataroom.tags.length - 3} more
</span>
)}
</div>
)}
</CardHeader>
<CardContent>
{/* Stats List */}
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Documents</div>
<div className="text-sm text-muted-foreground">
{dataroom._count.documents}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Views</div>
<div className="text-sm text-muted-foreground">
{dataroom._count.views}
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">Active Links</div>
<div className="text-sm text-muted-foreground">
{activeLinkCount}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t pt-3">
<div className="text-xs text-muted-foreground">
{lastViewedAt ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<span>
Viewed{" "}
{formatDistanceToNow(new Date(lastViewedAt), {
addSuffix: true,
})}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Last viewed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span>No views yet</span>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
{!hasDocuments ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<span>Add Documents</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Add documents to dataroom</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
!isActive && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<span>Share</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Share dataroom</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
</div>
</div>
</CardContent>
</Link>
</Card>
);
}
@@ -20,10 +20,6 @@ import { type DataroomFolderDocument } from "@/lib/swr/use-dataroom";
import { type DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { cn, nFormatter, timeAgo } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import BarChart from "@/components/shared/icons/bar-chart";
import { Button } from "@/components/ui/button";
@@ -65,12 +61,6 @@ export default function DataroomDocumentCard({
theme === "light" || (theme === "system" && systemTheme === "light");
const router = useRouter();
// Get hierarchical display name
const displayName = useHierarchicalDisplayName(
dataroomDocument.document.name,
dataroomDocument.hierarchicalIndex,
);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
@@ -137,11 +127,7 @@ export default function DataroomDocumentCard({
{
method: "DELETE",
},
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to remove document");
}
).then(() => {
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${endpoint}`,
null,
@@ -159,7 +145,7 @@ export default function DataroomDocumentCard({
{
loading: "Removing document...",
success: "Document removed successfully.",
error: (err) => err.message || "Failed to remove document. Try again.",
error: "Failed to remove document. Try again.",
},
);
};
@@ -219,11 +205,8 @@ export default function DataroomDocumentCard({
<div className="flex-col">
<div className="flex items-center">
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{dataroomDocument.document.name}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+3 -10
View File
@@ -18,12 +18,10 @@ import {
export const DataroomHeader = ({
title,
description,
internalName,
actions,
}: {
title: string;
description: string;
internalName?: string | null;
actions?: React.ReactNode[];
}) => {
const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);
@@ -41,14 +39,9 @@ export const DataroomHeader = ({
<section className="mb-4">
<div className="flex items-center justify-between">
<div className="flex min-h-10 items-center gap-x-2 space-y-1">
<div>
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
{internalName || title}
</h1>
{internalName && (
<p className="text-sm text-muted-foreground">{title}</p>
)}
</div>
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
{dataroom?.enableChangeNotifications ? (
<Tooltip>
<TooltipTrigger asChild>
+2 -3
View File
@@ -618,7 +618,6 @@ export function DataroomItemsList({
setUploads={setUploads}
setRejectedFiles={setRejectedFiles}
dataroomId={dataroomId}
dataroomName={dataroom?.name}
>
{isMobile ? (
<div>
@@ -632,7 +631,7 @@ export function DataroomItemsList({
</Portal>
{mixedItems.length === 0 && (
<div className="flex h-full justify-center">
<EmptyDocuments isDataroom={true} />
<EmptyDocuments />
</div>
)}
</div>
@@ -701,7 +700,7 @@ export function DataroomItemsList({
{mixedItems.length === 0 && (
<div className="flex h-full justify-center">
<EmptyDocuments isDataroom={true} />
<EmptyDocuments />
</div>
)}
</DndContext>
+1 -1
View File
@@ -26,7 +26,7 @@ export const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {
segment: "analytics",
},
{
label: "Q&A",
label: "Q&A Conversations",
href: `/datarooms/${dataroomId}/conversations`,
segment: "conversations",
limited: !limits?.conversationsInDataroom,
+5 -30
View File
@@ -8,8 +8,6 @@ import { E164Number } from "libphonenumber-js";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,6 +21,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAnalytics } from "@/lib/analytics";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { PhoneInput } from "../ui/phone-input";
import {
@@ -55,26 +55,6 @@ export function DataroomTrialModal({
const teamInfo = useTeam();
const analytics = useAnalytics();
// Helper function to convert industry to proper dataroom name
const getDataroomName = (industryValue: string) => {
const industryNames: Record<string, string> = {
"finance-banking": "Finance and Banking Data Room",
legal: "Legal Data Room",
"real-estate": "Real Estate Data Room",
technology: "Technology Data Room",
pharmaceuticals: "Pharmaceuticals Data Room",
energy: "Energy Data Room",
manufacturing: "Manufacturing Data Room",
healthcare: "Healthcare Data Room",
consulting: "Consulting and Professional Services Data Room",
government: "Government and Public Sector Data Room",
entertainment: "Entertainment and Media Data Room",
other: "Data Room",
};
return industryNames[industryValue] || "Data Room";
};
const handleSubmit = async (event: any) => {
event.preventDefault();
event.stopPropagation();
@@ -86,8 +66,6 @@ export function DataroomTrialModal({
setLoading(true);
const dataroomName = getDataroomName(industry);
try {
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,
@@ -97,7 +75,7 @@ export function DataroomTrialModal({
"Content-Type": "application/json",
},
body: JSON.stringify({
name: dataroomName,
name: "Dataroom #1",
fullName: name,
companyName,
industry,
@@ -115,16 +93,13 @@ export function DataroomTrialModal({
}
analytics.capture("Dataroom Trial Created", {
dataroomName: dataroomName,
dataroomName: "Dataroom #1",
industry,
companySize,
});
toast.success("Dataroom successfully created! 🎉");
await Promise.all([
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
]);
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
router.push("/datarooms");
} catch (error) {
setLoading(false);
+46 -239
View File
@@ -3,10 +3,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -35,9 +31,6 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -50,45 +43,18 @@ export function ExportVisitsModal({
};
}, []);
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "GET" });
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, dataroomId, groupId]);
const startNewExport = useCallback(async () => {
const startExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -106,11 +72,10 @@ export function ExportVisitsModal({
}
// Trigger the background export job
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "POST" });
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -209,6 +174,11 @@ export function ExportVisitsModal({
}
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
@@ -231,72 +201,6 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -305,10 +209,6 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -324,9 +224,9 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Views</h3>
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
@@ -347,141 +247,48 @@ export function ExportVisitsModal({
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
<div className="text-sm text-gray-600">
Exporting views for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
>
Cancel Export
</Button>
</div>
)}
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
{viewCount !== null && (
<div className="text-sm text-gray-600">
Exports for: {displayName}
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.groupId && (
<div className="mt-1 text-xs text-gray-500">
Group: {exportJob.groupId}
</div>
)}
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || dataroomName,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={startNewExport}
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Start New Export
Email to {session.user.email}
</button>
</div>
</div>
)}
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
</div>
);
+6 -37
View File
@@ -9,10 +9,6 @@ import {
useDataroomFoldersTree,
} from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
import { FileTree } from "@/components/ui/nextra-filetree";
@@ -23,31 +19,6 @@ type MixedItem =
| (DataroomFolderWithDocuments & { itemType: "folder" })
| (DataroomFolderWithDocuments["documents"][0] & { itemType: "document" });
const DocumentFileItem = memo(
({
document,
router,
}: {
document: DataroomFolderWithDocuments["documents"][0] & {
itemType: "document";
};
router: any;
}) => {
const documentDisplayName = useHierarchicalDisplayName(
document.document.name,
document.hierarchicalIndex,
);
return (
<FileTree.File
name={documentDisplayName}
onToggle={() => router.push(`/documents/${document.document.id}`)}
/>
);
},
);
DocumentFileItem.displayName = "DocumentFileItem";
const FolderComponent = memo(
({
dataroomId,
@@ -58,12 +29,6 @@ const FolderComponent = memo(
}) => {
const router = useRouter();
// Get hierarchical display names
const folderDisplayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
const mixedItems = useMemo(() => {
const allItems: MixedItem[] = [
...(folder.childFolders || []).map((f) => ({
@@ -92,7 +57,11 @@ const FolderComponent = memo(
);
} else {
return (
<DocumentFileItem key={item.id} document={item} router={router} />
<FileTree.File
key={item.id}
name={item.document.name}
onToggle={() => router.push(`/documents/${item.document.id}`)}
/>
);
}
}),
@@ -118,7 +87,7 @@ const FolderComponent = memo(
return (
<FileTree.Folder
name={folderDisplayName}
name={folder.name}
key={folder.id}
active={isActive}
childActive={isChildActive}
-2
View File
@@ -61,7 +61,6 @@ type DataroomDocumentWithVersion = {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -76,7 +75,6 @@ type DataroomFolderWithDocumentsNew = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
+6 -62
View File
@@ -4,47 +4,17 @@ import { memo, useMemo } from "react";
import { DataroomFolder } from "@prisma/client";
import { HomeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { FileTree } from "@/components/ui/nextra-filetree";
import { buildNestedFolderStructureWithDocs } from "./utils";
const ViewerDocumentFileItem = memo(
({
document,
dataroomIndexEnabled,
}: {
document: DataroomDocumentWithVersion;
dataroomIndexEnabled?: boolean;
}) => {
const documentDisplayName = getHierarchicalDisplayName(
document.name,
document.hierarchicalIndex,
dataroomIndexEnabled || false,
);
return (
<FileTree.File
name={documentDisplayName}
// onToggle={() => router.push(`/documents/${document.id}`)}
/>
);
},
);
ViewerDocumentFileItem.displayName = "ViewerDocumentFileItem";
type DataroomDocumentWithVersion = {
dataroomDocumentId: string;
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -59,7 +29,6 @@ type DataroomFolderWithDocuments = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
@@ -90,37 +59,25 @@ const FolderComponent = memo(
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
}: {
folder: DataroomFolderWithDocuments;
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderPath: Set<string> | null;
dataroomIndexEnabled?: boolean;
}) => {
const router = useRouter();
// Get hierarchical display name for the folder
const folderDisplayName = getHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
dataroomIndexEnabled || false,
);
// Memoize the rendering of the current folder's documents
const documents = useMemo(
() =>
folder.documents.map((doc) => (
<ViewerDocumentFileItem
<FileTree.File
key={doc.id}
document={{
...doc,
versions: [], // Not needed for display
}}
dataroomIndexEnabled={dataroomIndexEnabled}
name={doc.name}
// onToggle={() => router.push(`/documents/${doc.id}`)}
/>
)),
[folder.documents, dataroomIndexEnabled],
[folder.documents, router.query.name],
);
// Recursively render child folders if they exist
@@ -133,16 +90,9 @@ const FolderComponent = memo(
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[
folder.childFolders,
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
],
[folder.childFolders, folderId, setFolderId],
);
const isActive = folder.id === folderId;
@@ -159,7 +109,7 @@ const FolderComponent = memo(
}}
>
<FileTree.Folder
name={folderDisplayName}
name={folder.name}
key={folder.id}
active={isActive}
childActive={isChildActive}
@@ -215,13 +165,11 @@ const SidebarFolders = ({
documents,
folderId,
setFolderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
dataroomIndexEnabled?: boolean;
}) => {
const nestedFolders = useMemo(() => {
if (folders) {
@@ -255,7 +203,6 @@ const SidebarFolders = ({
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
))}
</FileTree>
@@ -267,13 +214,11 @@ export function ViewFolderTree({
documents,
setFolderId,
folderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderId: string | null;
dataroomIndexEnabled?: boolean;
}) {
if (!folders) return null;
@@ -283,7 +228,6 @@ export function ViewFolderTree({
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
);
}
+170 -253
View File
@@ -1,23 +1,17 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { useUninvitedMembers } from "@/ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations";
import {
MailCheckIcon,
MoreHorizontalIcon,
PlusCircleIcon,
SendIcon,
UserXIcon,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { useDataroomGroup } from "@/lib/swr/use-dataroom-groups";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -37,10 +31,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { InviteViewersModal } from "../../../ee/features/dataroom-invitations/components/invite-viewers-modal";
import { AddGroupMemberModal } from "./add-member-modal";
export default function GroupMemberTable({
@@ -52,27 +44,13 @@ export default function GroupMemberTable({
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { dataroom } = useDataroom();
const { viewerGroupMembers, viewerGroupDomains, viewerGroupAllowAll } =
useDataroomGroup();
const {
uninvitedCount,
uninvitedEmails,
mutate: mutateUninvited,
} = useUninvitedMembers(dataroomId, groupId);
const { isFeatureEnabled } = useFeatureFlags();
const [addMembersOpen, setAddMembersOpen] = useState<boolean>(false);
const [inviteOpen, setInviteOpen] = useState<boolean>(false);
const groupKey = teamId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`
: null;
const handleToggleAllowAll = async () => {
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
{
@@ -100,9 +78,7 @@ export default function GroupMemberTable({
};
const handleRemoveDomain = async (domain: string) => {
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
{
@@ -127,9 +103,7 @@ export default function GroupMemberTable({
const handleRemoveMember = async (id: string) => {
// mutate the data optimistically
const key =
groupKey ??
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
mutate(
key,
(currentData: { members: any[] } | undefined) => {
@@ -161,209 +135,29 @@ export default function GroupMemberTable({
};
return (
<>
<div className="l">
<div className="mb-2 flex items-center justify-between md:mb-4">
<h2>All members</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={viewerGroupAllowAll}
onCheckedChange={handleToggleAllowAll}
aria-label="Allow all emails"
/>
<span className="text-sm text-muted-foreground">
Allow all emails
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setAddMembersOpen(true)}
className="h-8 gap-1"
disabled={viewerGroupAllowAll}
>
<PlusCircleIcon className="h-4 w-4" />
Add members
</Button>
{isFeatureEnabled("dataroomInvitations") && (
<Button
size="sm"
variant="secondary"
onClick={() => setInviteOpen(true)}
className="relative h-8 gap-1"
>
<SendIcon className="h-4 w-4" />
Share invite
{uninvitedCount > 0 ? (
<Badge
variant="secondary"
className="ml-2 h-5 rounded-full px-2 text-xs font-medium"
>
{uninvitedCount}
</Badge>
) : null}
</Button>
)}
</div>
<div className="l">
<div className="mb-2 flex items-center justify-between md:mb-4">
<h2>All members</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={viewerGroupAllowAll}
onCheckedChange={handleToggleAllowAll}
aria-label="Allow all emails"
/>
<span className="text-sm text-muted-foreground">
Allow all emails
</span>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
{/* <TableHead>Last Viewed</TableHead> */}
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{viewerGroupAllowAll ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
All email addresses are allowed to access this group
</p>
</div>
</TableCell>
</TableRow>
) : viewerGroupMembers.length === 0 &&
viewerGroupDomains.length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p>No views yet. Try sharing a link.</p>
</div>
</TableCell>
</TableRow>
) : (
<>
{viewerGroupDomains.length > 0 &&
viewerGroupDomains.map((domain) => (
<TableRow key={domain} className="group/row">
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={"@"} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{domain}
</p>
</div>
</div>
</div>
</TableCell>
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveDomain(domain)}
>
<XIcon className="h-4 w-4" />
Remove Domain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{viewerGroupMembers ? (
viewerGroupMembers.map((viewer) => {
const latestInvitation = viewer.viewer.invitations?.[0];
return (
<TableRow key={viewer.id} className="group/row">
{/* Name */}
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar
viewerEmail={viewer.viewer.email}
/>
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{viewer.viewer.email}
{latestInvitation && (
<TimestampTooltip
timestamp={latestInvitation.sentAt}
side="right"
rows={["local", "utc"]}
>
<MailCheckIcon className="h-4 w-4 text-blue-500 hover:text-blue-600" />
</TimestampTooltip>
)}
</p>
</div>
</div>
</div>
</TableCell>
{/* Last Viewed */}
{/* <TableCell className="text-sm text-muted-foreground">
<time
dateTime={new Date(viewer.viewer.updatedAt).toISOString()}
>
{viewer.viewer.updatedAt
? timeAgo(viewer.viewer.updatedAt)
: "-"}
</time>
</TableCell> */}
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveMember(viewer.id)}
>
<UserXIcon className="h-4 w-4" />
Remove Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
<Button
size="sm"
onClick={() => setAddMembersOpen(true)}
className="h-8 gap-1"
disabled={viewerGroupAllowAll}
>
<PlusCircleIcon className="h-4 w-4" />
Add members
</Button>
</div>
</div>
<AddGroupMemberModal
@@ -372,27 +166,150 @@ export default function GroupMemberTable({
open={addMembersOpen}
setOpen={setAddMembersOpen}
/>
{isFeatureEnabled("dataroomInvitations") && (
<InviteViewersModal
open={inviteOpen}
setOpen={(next) => {
setInviteOpen(next);
if (!next) {
mutateUninvited();
}
}}
dataroomId={dataroomId}
dataroomName={dataroom?.name ?? "this dataroom"}
groupId={groupId}
defaultEmails={uninvitedEmails}
onSuccess={() => {
if (groupKey) {
mutate(groupKey);
}
mutateUninvited();
}}
/>
)}
</>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
<TableHead>Name</TableHead>
{/* <TableHead>Visit Duration</TableHead> */}
{/* <TableHead>Last Viewed Document</TableHead> */}
{/* <TableHead>Last Viewed</TableHead> */}
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{viewerGroupAllowAll ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p className="text-sm text-muted-foreground">
All email addresses are allowed to access this group
</p>
</div>
</TableCell>
</TableRow>
) : viewerGroupMembers.length === 0 &&
viewerGroupDomains.length === 0 ? (
<TableRow>
<TableCell colSpan={2}>
<div className="flex h-40 w-full items-center justify-center">
<p>No visits yet. Try sharing a link.</p>
</div>
</TableCell>
</TableRow>
) : (
<>
{viewerGroupDomains.length > 0 &&
viewerGroupDomains.map((domain) => (
<TableRow key={domain} className="group/row">
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={"@"} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{domain}
</p>
</div>
</div>
</div>
</TableCell>
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveDomain(domain)}
>
<XIcon className="h-4 w-4" />
Remove Domain
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{viewerGroupMembers ? (
viewerGroupMembers.map((viewer) => (
<TableRow key={viewer.id} className="group/row">
{/* Name */}
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={viewer.viewer.email} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{viewer.viewer.email}
</p>
</div>
</div>
</div>
</TableCell>
{/* Last Viewed */}
{/* <TableCell className="text-sm text-muted-foreground">
<time
dateTime={new Date(viewer.viewer.updatedAt).toISOString()}
>
{viewer.viewer.updatedAt
? timeAgo(viewer.viewer.updatedAt)
: "-"}
</time>
</TableCell> */}
{/* Actions */}
<TableCell className="p-0 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
>
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => handleRemoveMember(viewer.id)}
>
<UserXIcon className="h-4 w-4" />
Remove Member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="min-w-[100px]">
<Skeleton className="h-6 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24" />
</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
</Table>
</div>
</div>
);
}
+114 -367
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTeam } from "@/context/team-context";
import { ItemType, ViewerGroupAccessControls } from "@prisma/client";
@@ -19,22 +19,15 @@ import {
EyeOffIcon,
File,
Folder,
HomeIcon,
} from "lucide-react";
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroomFoldersTree } from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import CloudDownloadOff from "@/components/shared/icons/cloud-download-off";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
@@ -45,39 +38,10 @@ import {
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const PermissionItemName = ({ item }: { item: FileOrFolder }) => {
const { isFeatureEnabled } = useFeatureFlags();
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const displayName = getHierarchicalDisplayName(
item.name,
item.hierarchicalIndex,
isDataroomIndexEnabled,
);
const isRoot = item.id === "__dataroom_root__";
return (
<div className="flex items-center text-foreground">
{isRoot ? (
<HomeIcon className="mr-2 h-5 w-5" />
) : item.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate" style={HIERARCHICAL_DISPLAY_STYLE}>
{displayName}
</span>
</div>
);
};
// Update the FileOrFolder type to include permissions
type FileOrFolder = {
id: string;
name: string;
hierarchicalIndex?: string | null;
subItems?: FileOrFolder[];
permissions: {
view: boolean;
@@ -99,35 +63,38 @@ type ColumnExtra = {
};
const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.getCanExpand() ? (
<Button
variant="ghost"
onClick={row.getToggleExpandedHandler()}
className="h-6 w-6 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : null;
},
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => {
const isRoot = row.original.id === "__dataroom_root__";
return (
<div className="flex items-center text-foreground">
{isRoot ? (
<div className="h-6 w-6 shrink-0" />
) : row.getCanExpand() ? (
<Button
variant="ghost"
onClick={row.getToggleExpandedHandler()}
className="mr-1 h-6 w-6 shrink-0 p-0"
disabled={isRoot}
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : (
<div className="mr-1 h-6 w-6 shrink-0" />
)}
<PermissionItemName item={row.original} />
</div>
);
},
cell: ({ row }) => (
<div className="flex items-center text-foreground">
{row.original.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate">{row.original.name}</span>
</div>
),
},
{
id: "actions",
@@ -193,7 +160,7 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
},
];
// Build tree function to include permissions
// Update the buildTree function to include permissions
const buildTree = (
items: any[],
permissions: ViewerGroupAccessControls[],
@@ -201,13 +168,9 @@ const buildTree = (
): FileOrFolder[] => {
const getPermissions = (id: string) => {
const permission = permissions.find((p) => p.itemId === id);
// If we have permission data loaded, use it. Otherwise default to true for consistency.
const hasPermissionData = permissions.length > 0;
return {
view: permission ? permission.canView : hasPermissionData ? false : true,
download: permission ? permission.canDownload : false,
view: permission?.canView ?? false,
download: permission?.canDownload ?? false,
partialView: false,
partialDownload: false,
};
@@ -226,7 +189,6 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
}));
@@ -235,40 +197,41 @@ const buildTree = (
const folderPermissions = getPermissions(folder.id);
// Calculate view and partialView for folders
let viewStatus = folderPermissions.view;
let partialView = false;
let downloadStatus = folderPermissions.download;
let partialDownload = false;
// Calculate view and partialView
const someSubItemViewable = allSubItems.some(
(subItem) => subItem.permissions.view,
);
const allSubItemsViewable = allSubItems.every(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = allSubItems.some(
(subItem) => subItem.permissions.download,
);
const allSubItemsDownloadable = allSubItems.every(
(subItem) => subItem.permissions.download,
);
if (allSubItems.length > 0) {
const viewableItems = allSubItems.filter(
(item) => item.permissions.view,
);
const downloadableItems = allSubItems.filter(
(item) => item.permissions.download,
);
folderPermissions.view = folderPermissions.view || someSubItemViewable;
folderPermissions.partialView =
someSubItemViewable && !allSubItemsViewable;
folderPermissions.download =
folderPermissions.download || someSubItemDownloadable;
folderPermissions.partialDownload =
someSubItemDownloadable && !allSubItemsDownloadable;
viewStatus = viewableItems.length > 0;
partialView =
viewableItems.length > 0 && viewableItems.length < allSubItems.length;
downloadStatus = downloadableItems.length > 0;
partialDownload =
downloadableItems.length > 0 &&
downloadableItems.length < allSubItems.length;
}
// Propagate view/download permission up if any subitem has view/download permission
folderPermissions.view =
folderPermissions.view ||
allSubItems.some((subItem) => subItem.permissions.view);
folderPermissions.download =
folderPermissions.download ||
allSubItems.some((subItem) => subItem.permissions.download);
result.push({
id: folder.id,
name: folder.name,
hierarchicalIndex: folder.hierarchicalIndex,
subItems: allSubItems,
permissions: {
view: viewStatus,
download: downloadStatus,
partialView,
partialDownload,
},
permissions: folderPermissions,
itemType: ItemType.DATAROOM_FOLDER,
});
});
@@ -285,7 +248,6 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
});
@@ -294,57 +256,6 @@ const buildTree = (
return result;
};
// Build tree with virtual root folder
const buildTreeWithRoot = (
items: any[],
permissions: ViewerGroupAccessControls[],
dataroomName: string = "Dataroom Home",
): FileOrFolder[] => {
// Get all items (folders and root documents)
const allItems = buildTree(items, permissions, null);
// Calculate overall permissions for the virtual root
const calculateRootPermissions = (items: FileOrFolder[]) => {
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.reduce((acc, item) => {
acc.push(item);
if (item.subItems) {
acc.push(...flattenItems(item.subItems));
}
return acc;
}, [] as FileOrFolder[]);
};
const allFlatItems = flattenItems(items);
const viewableItems = allFlatItems.filter((item) => item.permissions.view);
const downloadableItems = allFlatItems.filter(
(item) => item.permissions.download,
);
return {
view: viewableItems.length > 0,
download: downloadableItems.length > 0,
partialView:
viewableItems.length > 0 && viewableItems.length < allFlatItems.length,
partialDownload:
downloadableItems.length > 0 &&
downloadableItems.length < allFlatItems.length,
};
};
const rootPermissions = calculateRootPermissions(allItems);
return [
{
id: "__dataroom_root__",
name: dataroomName,
subItems: allItems,
permissions: rootPermissions,
itemType: ItemType.DATAROOM_FOLDER,
},
];
};
export default function ExpandableTable({
dataroomId,
groupId,
@@ -364,17 +275,8 @@ export default function ExpandableTable({
const [pendingChanges, setPendingChanges] = useState<ItemPermission>({});
const [debouncedPendingChanges] = useDebounce(pendingChanges, 2000);
// Use ref to access current data without dependency
const dataRef = useRef<FileOrFolder[]>([]);
useEffect(() => {
dataRef.current = data;
}, [data]);
const updatePermissions = useCallback(
(id: string, newPermissions: string[]) => {
const isRoot = id === "__dataroom_root__";
const findItemAndParents = (
items: FileOrFolder[],
targetId: string,
@@ -395,7 +297,7 @@ export default function ExpandableTable({
return null;
};
const result = findItemAndParents(dataRef.current, id);
const result = findItemAndParents(data, id);
if (!result) return;
const { item, parents } = result;
@@ -422,80 +324,6 @@ export default function ExpandableTable({
updatedPermissions.view = true;
}
// Handle root-level permissions (affects all items)
if (isRoot) {
setData((prevData) => {
const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.map((currentItem) => {
if (currentItem.id === "__dataroom_root__") {
return {
...currentItem,
permissions: {
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
subItems: currentItem.subItems
? updateAllItems(currentItem.subItems)
: undefined,
};
}
const updatedItem = {
...currentItem,
permissions: {
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
subItems: currentItem.subItems
? updateAllItems(currentItem.subItems)
: undefined,
};
return updatedItem;
});
};
return updateAllItems(prevData);
});
// Collect changes for all items
const collectAllChanges = (items: FileOrFolder[]): ItemPermission => {
let changes: ItemPermission = {};
const processItems = (items: FileOrFolder[]) => {
items.forEach((item) => {
// Don't save the virtual __dataroom_root__ item to database
if (item.id !== "__dataroom_root__") {
changes[item.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: item.itemType,
};
}
if (item.subItems) {
processItems(item.subItems);
}
});
};
processItems(items);
return changes;
};
const rootChanges = collectAllChanges(dataRef.current);
setPendingChanges((prev) => ({
...prev,
...rootChanges,
}));
return;
}
setData((prevData) => {
const updateItemInTree = (items: FileOrFolder[]): FileOrFolder[] => {
return items.map((currentItem) => {
@@ -506,7 +334,6 @@ export default function ExpandableTable({
view: updatedPermissions.view,
download: updatedPermissions.download,
partialView: false,
partialDownload: false,
},
};
@@ -565,52 +392,6 @@ export default function ExpandableTable({
parent: FileOrFolder,
subItems: FileOrFolder[],
): FileOrFolder => {
const isParentRoot = parent.id === "__dataroom_root__";
// For root folder, calculate based on all descendants
const calculatePermissions = (items: FileOrFolder[]) => {
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
return items.reduce((acc, item) => {
if (item.id !== "__dataroom_root__") {
acc.push(item);
}
if (item.subItems) {
acc.push(...flattenItems(item.subItems));
}
return acc;
}, [] as FileOrFolder[]);
};
const allItems = flattenItems(items);
const viewableItems = allItems.filter(
(item) => item.permissions.view,
);
const downloadableItems = allItems.filter(
(item) => item.permissions.download,
);
return {
view: viewableItems.length > 0,
partialView:
viewableItems.length > 0 &&
viewableItems.length < allItems.length,
download: downloadableItems.length > 0,
partialDownload:
downloadableItems.length > 0 &&
downloadableItems.length < allItems.length,
};
};
if (isParentRoot) {
const rootPermissions = calculatePermissions(subItems);
return {
...parent,
permissions: rootPermissions,
subItems,
};
}
// For regular folders
const someSubItemViewable = subItems.some(
(subItem) => subItem.permissions.view,
);
@@ -640,21 +421,18 @@ export default function ExpandableTable({
return updateItemInTree(prevData);
});
// Collect changes for database update
// database changes
const collectChanges = (
item: FileOrFolder,
parents: FileOrFolder[],
): ItemPermission => {
let changes: ItemPermission = {};
// Don't save the virtual __dataroom_root__ item to database
if (item.id !== "__dataroom_root__") {
changes[item.id] = {
let changes: ItemPermission = {
[item.id]: {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: item.itemType,
};
}
},
};
// Collect changes for all subitems
const collectSubItemChanges = (
@@ -662,14 +440,11 @@ export default function ExpandableTable({
) => {
if (!subItems) return;
subItems.forEach((subItem) => {
// Don't save the virtual __dataroom_root__ item to database
if (subItem.id !== "__dataroom_root__") {
changes[subItem.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: subItem.itemType,
};
}
changes[subItem.id] = {
view: updatedPermissions.view,
download: updatedPermissions.download,
itemType: subItem.itemType,
};
collectSubItemChanges(subItem.subItems);
});
};
@@ -677,40 +452,35 @@ export default function ExpandableTable({
collectSubItemChanges(item.subItems);
// Ensure all parent folders are viewable if the item is being set to viewable
// and downloadable if the item is being set to downloadable
if (updatedPermissions.view || updatedPermissions.download) {
parents.forEach((parent) => {
// Don't save the virtual __dataroom_root__ item to database
if (parent.id !== "__dataroom_root__") {
changes[parent.id] = {
view: true,
download: updatedPermissions.download
? true
: parent.permissions.download,
itemType: parent.itemType,
};
}
changes[parent.id] = {
view: true, // Always enable view for parent folders if child is viewable
download: updatedPermissions.download
? true
: parent.permissions.download, // Always enable download for parent folders if child is downloadable
itemType: parent.itemType,
};
});
} else {
// If turning off view, recalculate parent permissions
[...parents].reverse().forEach((parent) => {
// Don't save the virtual __dataroom_root__ item to database
if (parent.id !== "__dataroom_root__") {
const otherChildren =
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
[];
const someSubItemViewable = otherChildren.some(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = otherChildren.some(
(subItem) => subItem.permissions.download,
);
const otherChildren =
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
[];
const someSubItemViewable = otherChildren.some(
(subItem) => subItem.permissions.view,
);
const someSubItemDownloadable = otherChildren.some(
(subItem) => subItem.permissions.download,
);
changes[parent.id] = {
view: someSubItemViewable,
download: someSubItemDownloadable,
itemType: parent.itemType,
};
}
changes[parent.id] = {
view: someSubItemViewable,
download: someSubItemDownloadable,
itemType: parent.itemType,
};
});
}
@@ -722,12 +492,12 @@ export default function ExpandableTable({
...collectChanges(item, parents),
}));
},
[],
[data],
);
useEffect(() => {
if (folders && !loading) {
const treeData = buildTreeWithRoot(folders, permissions, "Dataroom Home");
const treeData = buildTree(folders, permissions);
setData(treeData);
}
}, [folders, loading, permissions]);
@@ -784,18 +554,6 @@ export default function ExpandableTable({
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row) => row.subItems,
initialState: {
expanded: {
"0": true, // Always expand the root folder (first row)
},
},
getRowCanExpand: (row) => {
// Root folder is always expanded and cannot be collapsed
if (row.original.id === "__dataroom_root__") {
return true;
}
return (row.subRows?.length ?? 0) > 0;
},
});
if (loading) return <div>Loading...</div>;
@@ -824,35 +582,24 @@ export default function ExpandableTable({
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const isRoot = row.original.id === "__dataroom_root__";
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(isRoot && "bg-blue-50/50 dark:bg-blue-950/50")}
>
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
style={
index === 0
? {
paddingLeft: `${row.depth * 1.25}rem`,
}
: undefined
}
className="py-2 last:flex last:justify-end"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
paddingLeft: `${row.depth * 1.25 + 1}rem`,
}}
className="py-2 last:flex last:justify-end"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
@@ -5,9 +5,6 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { SidebarFolderTreeSelection } from "@/components/datarooms/folders";
import { Button } from "@/components/ui/button";
import {
@@ -19,6 +16,9 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { TSelectedFolder } from "../documents/move-folder-modal";
export function MoveToDataroomFolderModal({
@@ -110,7 +110,7 @@ export function MoveToDataroomFolderModal({
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<SidebarFolderTreeSelection
dataroomId={dataroomId}
selectedFolder={selectedFolder}
@@ -133,9 +133,7 @@ export function MoveToDataroomFolderModal({
) : (
<>
Move to{" "}
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
<span className="font-medium">{selectedFolder.name}</span>
</>
)}
</Button>
@@ -1,104 +0,0 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface BulkDownloadSettingsProps {
dataroomId: string;
}
export default function BulkDownloadSettings({
dataroomId,
}: BulkDownloadSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
name: string;
pId: string;
allowBulkDownload: boolean;
}>(
dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,
fetcher,
);
const [isUpdating, setIsUpdating] = useState(false);
const handleBulkDownloadToggle = async (checked: boolean) => {
if (!dataroomId || !teamId || isUpdating) return;
setIsUpdating(true);
toast.promise(
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
allowBulkDownload: checked,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error("Failed to update bulk download settings");
}
await mutateDataroom();
}),
{
loading: "Updating bulk download settings...",
success: "Bulk download settings updated successfully",
error: "Failed to update bulk download settings",
},
);
setIsUpdating(false);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center">Dataroom Download</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label
htmlFor="bulk-download-toggle"
className="text-sm font-medium"
>
Allow bulk download of entire dataroom
</Label>
</div>
<Switch
id="bulk-download-toggle"
checked={dataroomData?.allowBulkDownload ?? true}
onCheckedChange={handleBulkDownloadToggle}
disabled={isUpdating}
/>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6">
<p className="text-sm text-muted-foreground transition-colors">
When enabled, visitors can download all dataroom contents as a single
ZIP file. Individual document and folder downloads will still work
regardless of this setting.
</p>
</CardFooter>
</Card>
);
}
@@ -1,238 +0,0 @@
import Link from "next/link";
import { Dispatch, SetStateAction, useMemo, useState } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import { CircleHelpIcon, Tag } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { useTags } from "@/lib/swr/use-tags";
import { TagColorProps } from "@/lib/types";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { MultiSelect } from "@/components/ui/multi-select-v2";
import { BadgeTooltip } from "@/components/ui/tooltip";
type TagProps = {
id: string;
name: string;
color: string;
description: string | null;
};
function getTagOption(tag: TagProps) {
return {
value: tag.id,
label: tag.name,
icon: (
<Tag
size={20}
className={`rounded-sm border border-gray-200 bg-${tag.color}-100 p-1 dark:text-primary-foreground`}
/>
),
meta: { color: tag.color, description: tag.description },
};
}
interface DataroomTagSectionProps {
dataroomId: string;
teamId: string;
initialTags?: {
tag: {
id: string;
name: string;
color: string;
description: string | null;
};
}[];
}
export default function DataroomTagSection({
dataroomId,
teamId,
initialTags,
}: DataroomTagSectionProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState<string[]>(
initialTags?.map((t) => t.tag.id) || [],
);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { isFree } = usePlan();
const {
tagCount,
tags: availableTags,
loading: loadingTags,
} = useTags({
query: {
sortBy: "createdAt",
sortOrder: "desc",
},
});
const options = useMemo(
() => availableTags?.map((tag) => getTagOption(tag)),
[availableTags],
);
// Check if there are unsaved changes
const hasChanges = useMemo(() => {
const initialTagIds = initialTags?.map((t) => t.tag.id) || [];
if (selectedValues.length !== initialTagIds.length) return true;
return !selectedValues.every((id) => initialTagIds.includes(id));
}, [selectedValues, initialTags]);
// Callback to handle value change
const handleValueChange = (value: string[]) => {
setSelectedValues(value);
};
// Save tags to API
const handleSave = async () => {
setIsSaving(true);
try {
const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: selectedValues }),
});
if (!res.ok) {
const { error } = await res.json();
toast.error(error);
return;
}
await Promise.all([
mutate(`/api/teams/${teamId}/datarooms`),
mutate(`/api/teams/${teamId}/datarooms?simple=true`),
mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`),
]);
toast.success("Successfully updated tags!");
} catch (error) {
toast.error("Failed to update tags");
console.error("Error updating tags:", error);
} finally {
setIsSaving(false);
}
};
const createTag = async (tag: string) => {
if (isFree && tagCount && tagCount >= 5) {
setShowUpgradeModal(true);
toast.error("You have reached the maximum number of tags.");
return false;
}
const res = await fetch(`/api/teams/${teamId}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: tag }),
});
if (!res.ok) {
const { error } = await res.json();
toast.error(error);
return false;
}
const newTag = await res.json();
await mutate(
`/api/teams/${teamId}/tags?${new URLSearchParams({
sortBy: "createdAt",
sortOrder: "desc",
includeLinksCount: false,
} as Record<string, any>).toString()}`,
);
// Add to selected values - same pattern as link-sheet
setSelectedValues([...selectedValues, newTag.id]);
setIsPopoverOpen(false);
toast.success("Successfully created tag!");
return true;
};
return (
<>
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Tags</CardTitle>
<CardDescription>
Organize your dataroom by adding tags for better categorization and
filtering
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Label htmlFor="dataroom-tags">Tags</Label>
<BadgeTooltip content="Organize datarooms by tags to easily filter and find them">
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
</BadgeTooltip>
</div>
<Link
href={`/settings/tags`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Manage
</Link>
</div>
<div className="mt-2 flex gap-2">
<MultiSelect
loading={loadingTags}
options={options ?? []}
value={selectedValues}
setIsPopoverOpen={setIsPopoverOpen}
isPopoverOpen={isPopoverOpen}
onValueChange={handleValueChange}
placeholder="Select tags..."
maxCount={3}
searchPlaceholder="Search or add tags..."
onCreate={(search) => createTag(search)}
popoverClassName="sm:w-[400px]"
/>
<Button
onClick={handleSave}
loading={isSaving}
disabled={!hasChanges || isSaving}
size="default"
>
Save
</Button>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
<p className="text-sm text-muted-foreground transition-colors">
Tags help you organize and filter datarooms across your workspace.
</p>
</CardFooter>
</Card>
{showUpgradeModal && (
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="create_tag"
open={showUpgradeModal}
setOpen={setShowUpgradeModal}
/>
)}
</>
);
}
@@ -51,10 +51,7 @@ function DeleteDataroomModal({
dataroomName: dataroomName,
dataroomId: dataroomId,
});
await Promise.all([
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
]);
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
router.push("/datarooms");
resolve(null);
} else {
@@ -19,7 +19,7 @@ import {
} from "@/components/ui/card";
import { usePlan } from "@/lib/swr/use-billing";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import useDatarooms from "@/lib/swr/use-datarooms";
export default function DuplicateDataroom({
dataroomId,
@@ -33,7 +33,7 @@ export default function DuplicateDataroom({
const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);
const { limits } = useLimits();
const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();
const { datarooms: dataRooms } = useDataroomsSimple();
const { datarooms: dataRooms } = useDatarooms();
const numDatarooms = dataRooms?.length ?? 0;
const limitDatarooms = limits?.datarooms ?? 1;
@@ -75,7 +75,6 @@ export default function DuplicateDataroom({
loading: "Copying dataroom...",
success: (dataroom) => {
mutate(`/api/teams/${teamId}/datarooms`);
mutate(`/api/teams/${teamId}/datarooms?simple=true`);
router.push(`/datarooms/${dataroom.id}/documents`);
return "Dataroom copied successfully.";
},
@@ -83,7 +83,7 @@ export default function NotificationSettings({
<CardTitle>
Notifications{" "}
{!isDataroomsPlus && !features?.roomChangeNotifications ? (
<PlanBadge plan="data rooms plus" />
<PlanBadge plan="datarooms-plus" />
) : null}
</CardTitle>
<CardDescription>
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,18 +39,6 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
<BellIcon className="h-4 w-4" />
Notifications
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/downloads`}
className={cn(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("downloads"),
},
)}
>
<DownloadIcon className="h-4 w-4" />
Downloads
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/file-permissions`}
className={cn(
@@ -155,10 +155,10 @@ export function DataroomSortableList({
mutate(`${baseKey}/folders`);
mutate(`${baseKey}/folders?include_documents=true`);
setIsReordering(false);
toast.success("Folder order saved successfully");
toast.success("Index saved successfully");
} catch (error) {
console.error("Failed to save new order:", error);
toast.error("Failed to save order");
toast.error("Failed to save index");
// Optionally, show an error message to the user
} finally {
setIsReordering(false);
@@ -247,7 +247,7 @@ export function DataroomSortableList({
className="gap-x-1"
>
<CheckIcon className="size-4" />
Save order
Save index
</Button>
</Portal>
<DeleteFolderModal />
+4 -4
View File
@@ -1,10 +1,10 @@
import ErrorPage from "next/error";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
import StatsElement from "@/components/documents/stats-element";
import { Skeleton } from "@/components/ui/skeleton";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
export default function StatsCard() {
const { stats, loading, error } = useDataroomStats();
@@ -30,12 +30,12 @@ export default function StatsCard() {
const statistics = [
{
name: "Number of views",
name: "Number of visits",
value: stats?.dataroomViews.length.toString() ?? "0",
active: true,
},
{
name: "Number of documents views",
name: "Number of documents visits",
value: stats?.documentViews.length.toString() ?? "0",
active: true,
},
+46 -9
View File
@@ -7,11 +7,7 @@ import { useTheme } from "next-themes";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import {
FREE_PLAN_ACCEPTED_FILE_TYPES,
FULL_PLAN_ACCEPTED_FILE_TYPES,
SUPPORTED_DOCUMENT_MIME_TYPES,
} from "@/lib/constants";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { bytesToSize } from "@/lib/utils";
@@ -50,8 +46,49 @@ export default function DocumentUpload({
const { getRootProps, getInputProps } = useDropzone({
accept:
isFree && !isTrial
? FREE_PLAN_ACCEPTED_FILE_TYPES
: FULL_PLAN_ACCEPTED_FILE_TYPES,
? {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
}
: {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"application/vnd.ms-powerpoint": [], // ".ppt"
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[], // ".pptx"
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
"application/msword": [], // ".doc"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[], // ".docx"
"application/vnd.oasis.opendocument.text": [], // ".odt"
"image/vnd.dwg": [".dwg"], // ".dwg"
"image/vnd.dxf": [".dxf"], // ".dxf"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
"application/zip": [], // ".zip"
"application/x-zip-compressed": [], // ".zip"
"video/mp4": [], // ".mp4"
"video/quicktime": [], // ".mov"
"video/x-msvideo": [], // ".avi"
"video/webm": [], // ".webm"
"video/ogg": [], // ".ogg"
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
"application/vnd.ms-outlook": [".msg"], // ".msg"
},
multiple: false,
onDropAccepted: (acceptedFiles) => {
if (acceptedFiles.length === 0) {
@@ -191,8 +228,8 @@ export default function DocumentUpload({
{currentFile
? "Replace file?"
: isFree && !isTrial
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods, *.png, *.jpeg, *.jpg`
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.tsv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.rtf, *txt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.ods, *.png, *.jpeg, *.jpg`
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
</p>
</div>
</div>
@@ -12,13 +12,13 @@ import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { useAnalytics } from "@/lib/analytics";
function DeleteItemsModal({
showDeleteItemsModal,
setShowDeleteItemsModal,
@@ -75,7 +75,9 @@ function DeleteItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to delete document");
throw new Error(
`Failed to delete document ${documentId}: ${error.message}`,
);
}
analytics.capture("Document Deleted", {
team: teamInfo?.currentTeam?.id,
@@ -94,7 +96,9 @@ function DeleteItemsModal({
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to delete folder");
throw new Error(
`Failed to delete folder ${folderId}: ${error.message}`,
);
}
analytics.capture("Folder Deleted", {
team: teamInfo?.currentTeam?.id,
@@ -315,4 +319,4 @@ export function useDeleteDocumentsAndFoldersModal({
}),
[setShowDeleteItemsModal, DeleteItemsModalCallback],
);
}
}
@@ -37,13 +37,7 @@ export function useDeleteFolderModal(
{
method: "DELETE",
},
).then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to delete folder");
}
return res;
}),
),
{
loading: isDataroom ? "Removing folder..." : "Deleting folder...",
success: () => {
@@ -60,11 +54,9 @@ export function useDeleteFolderModal(
? "Folder removed successfully."
: `Folder deleted successfully with ${folderToDelete?._count.documents} documents and ${folderToDelete?._count.childFolders} folders`;
},
error: (err) =>
err.message ||
(isDataroom
? "Failed to remove folder."
: "Failed to delete folder. Move documents first."),
error: isDataroom
? "Failed to remove folder."
: "Failed to delete folder. Move documents first.",
},
);
};
+41 -322
View File
@@ -9,7 +9,6 @@ import { DefaultPermissionStrategy } from "@prisma/client";
import { parsePageId } from "notion-utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import {
@@ -19,7 +18,6 @@ import {
} from "@/lib/documents/create-document";
import { putFile } from "@/lib/files/put-file";
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
import { getNotionPageIdFromSlug } from "@/lib/notion/utils";
import { usePlan } from "@/lib/swr/use-billing";
import { useDataroom } from "@/lib/swr/use-dataroom";
import useLimits from "@/lib/swr/use-limits";
@@ -74,8 +72,6 @@ export function AddDocumentModal({
const [isOpen, setIsOpen] = useState<boolean | undefined>(undefined);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [notionLink, setNotionLink] = useState<string | null>(null);
const [webLink, setWebLink] = useState<string | null>(null);
const [uploadMode, setUploadMode] = useState<"file" | "link">("file");
const [showGroupPermissions, setShowGroupPermissions] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<
{
@@ -86,7 +82,7 @@ export function AddDocumentModal({
>([]);
const teamInfo = useTeam();
const { canAddDocuments } = useLimits();
const { plan, isFree, isTrial, isPaused } = usePlan();
const { plan, isFree, isTrial } = usePlan();
const { dataroom } = useDataroom();
const teamId = teamInfo?.currentTeam?.id as string;
@@ -208,20 +204,6 @@ export function AddDocumentModal({
): Promise<void> => {
event.preventDefault();
// Check if team is paused
if (isPaused) {
toast.error(
"Your subscription is paused. Resume your subscription to upload documents.",
{
action: {
label: "Go to Billing",
onClick: () => router.push("/settings/billing"),
},
},
);
return;
}
// Check if the file is chosen
if (!currentFile) {
toast.error("Please select a file to upload.");
@@ -400,55 +382,22 @@ export function AddDocumentModal({
): Promise<void> => {
event.preventDefault();
// Check if team is paused
if (isPaused) {
toast.error(
"Your subscription is paused. Resume your subscription to upload documents.",
{
action: {
label: "Go to Billing",
onClick: () => router.push("/settings/billing"),
},
},
);
return;
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
return;
}
const validateNotionPageURL = parsePageId(notionLink);
// Check if it's a valid URL or not by Regx
const isValidURL =
/^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/;
// Check if the field is empty or not
if (!notionLink) {
toast.error("Please enter a Notion link to proceed.");
return; // prevent form from submitting
}
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(notionLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
// Try to validate the Notion page URL
let validateNotionPageId = parsePageId(notionLink);
// If parsePageId fails, try to get page ID from slug
if (validateNotionPageId === null) {
try {
const pageId = await getNotionPageIdFromSlug(notionLink);
validateNotionPageId = pageId || undefined;
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}
if (!validateNotionPageId) {
if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
@@ -554,147 +503,9 @@ export function AddDocumentModal({
}
};
const handleWebLinkUpload = async (
event: FormEvent<HTMLFormElement>,
): Promise<void> => {
event.preventDefault();
// Check if user is on a free plan (not trial)
if (isFree && !isTrial) {
toast.error("Web links are available on Pro plan and above.");
return;
}
if (!canAddDocuments) {
toast.error("You have reached the maximum number of documents.");
return;
}
// Check if the field is empty or not
if (!webLink) {
toast.error("Please enter a website URL to proceed.");
return;
}
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(webLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
try {
setUploading(true);
// Extract domain name from URL for the document name
let linkName = "Web Link";
try {
const url = new URL(webLink);
linkName = url.hostname.replace("www.", "");
} catch (e) {
// Use default name if URL parsing fails
}
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/documents`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: linkName,
url: webLink,
numPages: 1,
type: "link",
createLink: false,
folderPathName: currentFolderPath?.join("/"),
}),
},
);
if (!response.ok) {
const { error } = await response.json();
toast.error(error);
return;
}
const document = await response.json();
if (isDataroom && dataroomId) {
const dataroomResponse = await addDocumentToDataroom({
documentId: document.id,
folderPathName: currentFolderPath?.join("/"),
});
if (dataroomResponse?.ok) {
const dataroomDocument =
(await dataroomResponse.json()) as DataroomDocument & {
dataroom: {
_count: { viewerGroups: number; permissionGroups: number };
};
};
await applyUnifiedPermissionsToDocument(
document,
dataroomDocument,
currentFolderPath,
);
}
analytics.capture("Document Added", {
documentId: document.id,
name: document.name,
numPages: document.numPages,
path: router.asPath,
type: "link",
teamId: teamId,
dataroomId: dataroomId,
$set: {
teamId: teamId,
teamPlan: plan,
},
});
return;
}
if (!newVersion) {
toast.success("Web link added. Redirecting to document page...");
analytics.capture("Document Added", {
documentId: document.id,
name: document.name,
fileSize: null,
path: router.asPath,
type: "link",
teamId: teamId,
$set: {
teamId: teamId,
teamPlan: plan,
},
});
// redirect to the document page
router.push("/documents/" + document.id);
}
} catch (error) {
setUploading(false);
toast.error("An error occurred while processing the web link.");
console.error("An error occurred while processing the web link: ", error);
} finally {
setUploading(false);
setIsOpen(false);
}
};
const clearModelStates = () => {
currentFile !== null && setCurrentFile(null);
notionLink !== null && setNotionLink(null);
webLink !== null && setWebLink(null);
setUploadMode("file");
setIsOpen(!isOpen);
setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen);
};
@@ -751,7 +562,7 @@ export function AddDocumentModal({
</CardTitle>
<CardDescription>
{newVersion ? (
`After you upload a new version, the existing links will remain unchanged.`
`After you upload a new version, the existing links will remain the unchanged.`
) : (
<span>
After you upload the document, create a shareable link.{" "}
@@ -773,141 +584,49 @@ export function AddDocumentModal({
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{uploadMode === "file" ? (
<form
encType="multipart/form-data"
onSubmit={handleFileUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
</div>
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !currentFile}
loading={uploading}
>
{uploading ? "Uploading..." : "Upload Document"}
</Button>
</div>
{!newVersion && (
<div className="flex justify-center">
<p className="text-sm text-muted-foreground">
Want to{" "}
<button
type="button"
className="underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={(e) => {
e.stopPropagation();
document
.getElementById("upload-multi-files-zone")
?.click();
clearModelStates();
}}
>
upload multiple files
</button>{" "}
or{" "}
{isFree && !isTrial ? (
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger={"add_web_link_document"}
>
<button
type="button"
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
>
share link as a document
</button>
</UpgradePlanModal>
) : (
<button
type="button"
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={(e) => {
e.preventDefault();
setUploadMode("link");
}}
>
share link as a document
</button>
)}
?
</p>
</div>
)}
</form>
) : (
<form
encType="multipart/form-data"
onSubmit={handleWebLinkUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<Label htmlFor="web-link">Website URL</Label>
<div className="mt-2">
<input
type="text"
name="web-link"
id="web-link"
placeholder="https://example.com"
className="flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6"
value={webLink || ""}
onChange={(e) => setWebLink(e.target.value)}
/>
</div>
{/* <small className="text-xs text-muted-foreground">
The page will be captured and converted to a document format.
</small> */}
</div>
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !webLink}
loading={uploading}
>
{uploading ? "Saving..." : "Save Web Link"}
</Button>
<form
encType="multipart/form-data"
onSubmit={handleFileUpload}
className="flex flex-col space-y-4"
>
<div className="space-y-1">
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
</div>
{!newVersion ? (
<div className="flex justify-center">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
className="text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
onClick={(e) => {
e.preventDefault();
setUploadMode("file");
e.stopPropagation();
document
.getElementById("upload-multi-files-zone")
?.click();
clearModelStates();
}}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Back to file upload
Want to upload multiple files?
</button>
</div>
</form>
)}
) : null}
<div className="flex justify-center">
<Button
type="submit"
className="w-full lg:w-1/2"
disabled={uploading || !currentFile}
loading={uploading}
>
{uploading ? "Uploading..." : "Upload Document"}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
@@ -15,7 +15,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import useDatarooms from "@/lib/swr/use-datarooms";
import {
Select,
@@ -46,7 +46,7 @@ export function AddToDataroomModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { datarooms } = useDataroomsSimple();
const { datarooms } = useDatarooms();
const handleSubmit = async (event: any) => {
event.preventDefault();
@@ -88,7 +88,7 @@ export function AddToDataroomModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-[90vw] sm:max-w-[425px]">
<DialogContent className="sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>
<span className="font-bold">{documentName}</span>
@@ -98,21 +98,18 @@ export function AddToDataroomModal({
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectTrigger className="min-w-fit">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent className="w-[380px] max-w-[90vw]">
<SelectContent>
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
<span className="line-clamp-1 break-words">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</SelectItem>
))}
</SelectContent>
@@ -130,15 +127,15 @@ export function AddToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<span className="flex w-full max-w-[350px] items-center justify-center truncate">
Add to
<span className="ml-1 line-clamp-1 truncate font-medium">
<>
Add to{" "}
<span className="font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</span>
</>
)}
</Button>
</DialogFooter>
@@ -16,7 +16,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
import useDatarooms from "@/lib/swr/use-datarooms";
import {
Select,
@@ -48,7 +48,7 @@ export function AddFolderToDataroomModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { datarooms } = useDataroomsSimple();
const { datarooms } = useDatarooms();
const handleSubmit = async (event: any) => {
event.preventDefault();
@@ -114,21 +114,18 @@ export function AddFolderToDataroomModal({
<DialogDescription>Add your folder to a dataroom.</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectTrigger className="min-w-fit">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent className="w-[380px] max-w-[90vw]">
<SelectContent>
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
<span className="break-words line-clamp-1">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</SelectItem>
))}
</SelectContent>
@@ -146,15 +143,15 @@ export function AddFolderToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<span className="flex items-center justify-center w-full max-w-[350px] truncate">
Add to
<span className="font-medium truncate line-clamp-1 ml-1">
<>
Add to{" "}
<span className="font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</span>
</>
)}
</Button>
</DialogFooter>
@@ -1,231 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { uploadImage } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form-hook";
import { Input } from "@/components/ui/input";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(100, "Title must be less than 100 characters"),
content: z.any().optional(),
pages: z.array(z.number()).min(1, "At least one page must be selected"),
isVisible: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface AnnotationFormProps {
documentId: string;
teamId: string;
numPages: number;
annotation?: any;
onSuccess: () => void;
}
export function AnnotationForm({
documentId,
teamId,
numPages,
annotation,
onSuccess,
}: AnnotationFormProps) {
const [isLoading, setIsLoading] = useState(false);
const [editorContent, setEditorContent] = useState(
annotation?.content || { type: "doc", content: [] },
);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: annotation?.title || "",
content: annotation?.content || null,
pages: annotation?.pages || [],
isVisible:
annotation?.isVisible !== undefined ? annotation.isVisible : true,
},
});
const pageOptions = Array.from({ length: numPages }, (_, i) => i + 1);
const handleImageUpload = async (file: File): Promise<string> => {
try {
// Upload the image using the existing uploadImage utility
const imageUrl = await uploadImage(file, "assets");
// Don't save to database here - let the form submission handle it
// Images will be embedded in the rich text content and parsed when saving
return imageUrl;
} catch (error) {
console.error("Failed to upload image:", error);
throw new Error("Failed to upload image");
}
};
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const url = annotation
? `/api/teams/${teamId}/documents/${documentId}/annotations/${annotation.id}`
: `/api/teams/${teamId}/documents/${documentId}/annotations`;
const method = annotation ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...values,
content: editorContent,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to save annotation");
}
toast.success(
annotation
? "Annotation updated successfully"
: "Annotation created successfully",
);
onSuccess();
} catch (error) {
console.error("Error saving annotation:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save annotation",
);
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter annotation title" {...field} />
</FormControl>
<FormDescription>
A brief title for this annotation that viewers will see.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pages"
render={({ field }) => (
<FormItem>
<FormLabel>Pages</FormLabel>
<FormDescription>
Select which pages this annotation should appear on.
</FormDescription>
<div className="mt-2 grid grid-cols-10 gap-2">
{pageOptions.map((page) => (
<div key={page} className="flex items-center space-x-2">
<Checkbox
id={`page-${page}`}
checked={field.value.includes(page)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, page]);
} else {
field.onChange(field.value.filter((p) => p !== page));
}
}}
/>
<label
htmlFor={`page-${page}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{page}
</label>
</div>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Content</FormLabel>
<FormDescription>
Add rich text content and images to help explain this part of the
document.
</FormDescription>
<RichTextEditor
content={editorContent}
onChange={setEditorContent}
placeholder="Add your annotation content here..."
onImageUpload={handleImageUpload}
/>
</div>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Visible to viewers</FormLabel>
<FormDescription>
When enabled, viewers will be able to see this annotation when
viewing the document.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end space-x-2">
<Button type="submit" disabled={isLoading}>
{isLoading && <LoadingSpinner className="mr-2 h-4 w-4" />}
{annotation ? "Update Annotation" : "Create Annotation"}
</Button>
</div>
</form>
</Form>
);
}
@@ -1,340 +0,0 @@
"use client";
import { useState } from "react";
import { Edit2, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useAnnotations } from "@/lib/swr/use-annotations";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { AnnotationForm } from "./annotation-form";
interface AnnotationSheetProps {
documentId: string;
teamId: string;
numPages?: number;
trigger?: React.ReactNode;
}
export function AnnotationSheet({
documentId,
teamId,
numPages = 1,
trigger,
}: AnnotationSheetProps) {
const { annotations, mutate } = useAnnotations(documentId, teamId);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingAnnotation, setEditingAnnotation] = useState<any>(null);
const handleDelete = async (annotationId: string) => {
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error("Failed to delete annotation");
}
toast.success("Annotation deleted successfully");
mutate();
} catch (error) {
console.error("Error deleting annotation:", error);
toast.error("Failed to delete annotation");
}
};
const handleToggleVisibility = async (
annotationId: string,
isVisible: boolean,
) => {
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isVisible: !isVisible,
}),
},
);
if (!response.ok) {
throw new Error("Failed to update annotation");
}
toast.success(
`Annotation ${!isVisible ? "shown" : "hidden"} successfully`,
);
mutate();
} catch (error) {
console.error("Error updating annotation:", error);
toast.error("Failed to update annotation");
}
};
const handleFormSuccess = () => {
setIsCreateOpen(false);
setEditingAnnotation(null);
mutate();
};
return (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
{trigger || (
<Button variant="outline" size="sm">
Add Annotations
</Button>
)}
</SheetTrigger>
<SheetContent className="w-[600px] sm:max-w-[600px]">
<SheetHeader>
<SheetTitle>Document Annotations</SheetTitle>
<SheetDescription>
Manage annotations that viewers can see when viewing this document.
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{annotations?.length || 0} annotation
{annotations?.length !== 1 ? "s" : ""}
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Annotation
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Annotation</DialogTitle>
<DialogDescription>
Add a new annotation that viewers can see when viewing this
document.
</DialogDescription>
</DialogHeader>
<AnnotationForm
documentId={documentId}
teamId={teamId}
numPages={numPages}
onSuccess={handleFormSuccess}
/>
</DialogContent>
</Dialog>
</div>
<ScrollArea className="h-[calc(100vh-200px)]">
{annotations && annotations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-muted p-3">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-lg font-medium">No annotations yet</h3>
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
Create your first annotation to help viewers understand your
document better.
</p>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create First Annotation
</Button>
</div>
) : (
<div className="space-y-3">
{annotations?.map((annotation) => (
<div
key={annotation.id}
className="space-y-3 rounded-lg border p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{annotation.title}</h4>
{!annotation.isVisible && (
<Badge variant="secondary">Hidden</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Pages: {annotation.pages.join(", ")}</span>
<span></span>
<span>
Created by{" "}
{annotation.createdBy?.name ||
annotation.createdBy?.email}
</span>
<span></span>
<span>
{new Date(
annotation.createdAt,
).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleToggleVisibility(
annotation.id,
annotation.isVisible,
)
}
>
{annotation.isVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
<Dialog
open={editingAnnotation?.id === annotation.id}
onOpenChange={(open) =>
setEditingAnnotation(open ? annotation : null)
}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Annotation</DialogTitle>
<DialogDescription>
Update this annotation.
</DialogDescription>
</DialogHeader>
<AnnotationForm
documentId={documentId}
teamId={teamId}
numPages={numPages}
annotation={annotation}
onSuccess={handleFormSuccess}
/>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Annotation
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this annotation?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(annotation.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Preview of annotation content */}
{annotation.content && (
<div className="rounded bg-muted/50 p-3 text-sm text-muted-foreground">
<div className="line-clamp-3">
{/* Simple text preview */}
{typeof annotation.content === "object" &&
annotation.content.content
? annotation.content.content
.filter(
(node: any) => node.type === "paragraph",
)
.map((node: any) =>
node.content
?.filter(
(textNode: any) =>
textNode.type === "text",
)
.map((textNode: any) => textNode.text)
.join(" "),
)
.join(" ")
: "No content"}
</div>
{annotation.images && annotation.images.length > 0 && (
<div className="mt-2 flex gap-1">
{annotation.images.slice(0, 3).map((image) => (
<img
key={image.id}
src={image.url}
alt={image.filename}
className="h-8 w-8 rounded border object-cover"
/>
))}
{annotation.images.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded border bg-muted text-xs">
+{annotation.images.length - 3}
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</ScrollArea>
</div>
</SheetContent>
</Sheet>
);
}

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