Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
7367379a48 Merge branch 'main' into refactor/workspace-form 2026-04-18 09:31:44 +01:00
Henry
b0ac3e11de refactor: remove legacy code from NewWorkspaceForm 2026-04-18 09:12:48 +01:00
87 changed files with 1678 additions and 12984 deletions

View File

@@ -227,12 +227,6 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
</a>
## Sponsors ❤️
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
Proudly sponsored by [TestMu AI](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
## License 📝
Kan is licensed under the [AGPLv3 license](LICENSE).

View File

@@ -11,7 +11,6 @@ RUN apk update && \
apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
corepack prepare pnpm@9.14.2 --activate && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store

View File

@@ -238,9 +238,9 @@ checksums:
ZXSPJt/message: 1b5749b0cca6a62d75a577bee6804074
ZXSPJt/origin/0/0: 0627e0040ca4939c9a57349e98c51797
ZXSPJt/translation: 1b5749b0cca6a62d75a577bee6804074
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
YkfDoz/message: 5d4f1c56f4e9e579ec9967ca68cc64ac
YkfDoz/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
YkfDoz/translation: 5d4f1c56f4e9e579ec9967ca68cc64ac
3bqt9U/message: 2366ed295eb2c03c425559c24cb31606
3bqt9U/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
3bqt9U/translation: 2366ed295eb2c03c425559c24cb31606
@@ -322,9 +322,9 @@ checksums:
1rWxEw/message: 122c108794f321c10e05a08f09b11457
1rWxEw/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
1rWxEw/translation: 122c108794f321c10e05a08f09b11457
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
iH8pgl/message: f541015a827e37cb3b1234e56bc2aa3c
iH8pgl/origin/0/0: 69d8634b177fdf937764b406fd477547
iH8pgl/translation: f541015a827e37cb3b1234e56bc2aa3c
KNKCTb/message: 35919fd5b10ad212839a75e72829fe4e
KNKCTb/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
KNKCTb/translation: 35919fd5b10ad212839a75e72829fe4e
@@ -343,9 +343,9 @@ checksums:
UoSI%2Bi/message: 47f1051a134e111930be7f94179df4c8
UoSI%2Bi/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
UoSI%2Bi/translation: 47f1051a134e111930be7f94179df4c8
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
zt%2F6cS/message: aadb69a7f7421be6bb022fbde08a6d77
zt%2F6cS/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zt%2F6cS/translation: aadb69a7f7421be6bb022fbde08a6d77
qaS%2B1%2F/message: 4e25347caf65e1b63bec90e101b86ddd
qaS%2B1%2F/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
qaS%2B1%2F/translation: 4e25347caf65e1b63bec90e101b86ddd
@@ -372,7 +372,8 @@ checksums:
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
@@ -592,9 +593,9 @@ checksums:
EH8IL3/origin/0/0: c0b59968b2b234167c488a05cc38710b
EH8IL3/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
EH8IL3/translation: 6f79129c8f08ee54d858a2af57d16dd9
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/message: 38ff8d574865bb71d2f620bc4ba98c20
zMquA7/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
zMquA7/translation: 38ff8d574865bb71d2f620bc4ba98c20
VHS0aJ/message: 31d0962985c83a29558c98a765bb1b16
VHS0aJ/origin/0/0: 15792234f408027822ee71ccddde6f9b
VHS0aJ/translation: 31d0962985c83a29558c98a765bb1b16
@@ -614,10 +615,9 @@ checksums:
RRn9jf/origin/1/0: d6f5120f072a821219608624f361384e
RRn9jf/translation: 210b6ff8727f976182ec3f29ea3c7667
yz7wBu/message: 2c2e22f8424a1031de89063bd0022e16
yz7wBu/origin/0/0: 278a40eb45b1219f424703d0abc1d273
yz7wBu/origin/1/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/2/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/3/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
yz7wBu/origin/1/0: afee530613110e248ffde6b56872ebed
yz7wBu/origin/2/0: dcafca531d4dce1faef7788feabfb048
yz7wBu/translation: 2c2e22f8424a1031de89063bd0022e16
TcXcmk/message: a2da6c2339301e7c3ddf068c4ff9f7e8
TcXcmk/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
@@ -629,8 +629,9 @@ checksums:
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
VbyRUy/message: 502237cd1044491c48494aa60967a985
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
@@ -700,10 +701,10 @@ checksums:
AX4ktp/message: 4e6be22ade0188f408471325fe363575
AX4ktp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
AX4ktp/translation: 4e6be22ade0188f408471325fe363575
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
xGVfLh/message: 3cfba90b4600131e82fc4260c568d044
xGVfLh/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
xGVfLh/origin/1/0: 69d8634b177fdf937764b406fd477547
xGVfLh/translation: 3cfba90b4600131e82fc4260c568d044
J2Wbjt/message: 8ed03cf7c5e60a6edf3470a4558ff058
J2Wbjt/origin/0/0: d96af8a06c2e38a72238bcd16e426859
J2Wbjt/translation: 8ed03cf7c5e60a6edf3470a4558ff058
@@ -735,9 +736,6 @@ checksums:
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
@@ -834,11 +832,12 @@ checksums:
7RpHg%2F/origin/0/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/origin/1/0: c0b59968b2b234167c488a05cc38710b
7RpHg%2F/translation: 66f702512197d25cfdb117f2415ecd88
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
ZDPJBc/message: f9154d0c02a7db907f0a33fb5edd4be1
ZDPJBc/origin/0/0: 69d8634b177fdf937764b406fd477547
ZDPJBc/translation: f9154d0c02a7db907f0a33fb5edd4be1
j9IZZ0/message: 7ba841d0946eb04fa3d17d74365be37b
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -1050,9 +1049,9 @@ checksums:
bFREhu/message: b2cc9a311fc28d1594252f697c7f5342
bFREhu/origin/0/0: 97bd206a05414e2a571020d64e166093
bFREhu/translation: b2cc9a311fc28d1594252f697c7f5342
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
SUXBxp/message: 76e841ae8ec3340d7d0a5c2191abbea4
SUXBxp/origin/0/0: 69d8634b177fdf937764b406fd477547
SUXBxp/translation: 76e841ae8ec3340d7d0a5c2191abbea4
0%2BewPp/message: 785fe23c0eef0a5b60b5b2a88151de31
0%2BewPp/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
0%2BewPp/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1122,9 +1121,6 @@ checksums:
rarRW%2F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/origin/1/0: 8628ddb6f4ded25c0f17e2cbda8207d3
rarRW%2F/translation: 91553b606a6ebed3534cd2e5635ce004
sZixXc/translation: db4e3e372abb1ac510c62b14d544f0f8
sZixXc/message: db4e3e372abb1ac510c62b14d544f0f8
sZixXc/origin/0/0: 0627e0040ca4939c9a57349e98c51797
sVBn4K/message: 0d7979a25d290123ed8b8906bf468f1a
sVBn4K/origin/0/0: d465da67c0a870d198eb7f313569c137
sVBn4K/translation: 0d7979a25d290123ed8b8906bf468f1a
@@ -1336,9 +1332,9 @@ checksums:
csFbe%2B/message: e4e58e33d637282d141df466d729bc7c
csFbe%2B/origin/0/0: 94d3e20c29bb517dd394711d7524e478
csFbe%2B/translation: e4e58e33d637282d141df466d729bc7c
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
SUvm1Y/message: 6c0f26526f77e9abb92fb2b1106a66db
SUvm1Y/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
SUvm1Y/translation: 6c0f26526f77e9abb92fb2b1106a66db
cdyS7J/message: 0f10050aeed2555a34fe3c27ce616607
cdyS7J/origin/0/0: c0b59968b2b234167c488a05cc38710b
cdyS7J/origin/1/0: c0b59968b2b234167c488a05cc38710b
@@ -1384,9 +1380,6 @@ checksums:
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
VOwhhz/message: 613f43526ea128b2749f70878f10008c
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
@@ -1516,8 +1509,9 @@ checksums:
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -1601,9 +1595,9 @@ checksums:
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
vo2a%2Ba/message: fcf0f06f8b64b458c7ca6d95541a3cc8
vo2a%2Ba/origin/0/0: 69d8634b177fdf937764b406fd477547
vo2a%2Ba/translation: fcf0f06f8b64b458c7ca6d95541a3cc8
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
@@ -1821,9 +1815,6 @@ checksums:
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
nG7mus/translation: 03326832c645f4ddb873a6e7eab8a639
nG7mus/message: 03326832c645f4ddb873a6e7eab8a639
nG7mus/origin/0/0: 278a40eb45b1219f424703d0abc1d273
IrZaAn/message: 1fcebe9ddb46f722a57f195efddc695d
IrZaAn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
IrZaAn/translation: 1fcebe9ddb46f722a57f195efddc695d
@@ -1836,9 +1827,6 @@ checksums:
vwGkYB/message: 4c30501d085eaccea47af34212bb26a7
vwGkYB/origin/0/0: 0627e0040ca4939c9a57349e98c51797
vwGkYB/translation: 4c30501d085eaccea47af34212bb26a7
Xlkxmi/translation: 3251e7239a9b349a86d5785a113cd6b9
Xlkxmi/message: 3251e7239a9b349a86d5785a113cd6b9
Xlkxmi/origin/0/0: 0627e0040ca4939c9a57349e98c51797
lpIMne/message: 37ca1f4e0afc9a0b8e9617f767103c92
lpIMne/origin/0/0: 0627e0040ca4939c9a57349e98c51797
lpIMne/translation: 37ca1f4e0afc9a0b8e9617f767103c92
@@ -1880,9 +1868,9 @@ checksums:
8RdLDU/message: d7820b1bf4efecc61ed89234567aaa9c
8RdLDU/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
8RdLDU/translation: d7820b1bf4efecc61ed89234567aaa9c
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/message: 313804fe615d51e0f0cbd599161d1ade
Oi%2BJ%2BN/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
Oi%2BJ%2BN/translation: 313804fe615d51e0f0cbd599161d1ade
Iqh0Uv/message: 0cc9dcd6ac624383e9977f66d4b3b777
Iqh0Uv/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
Iqh0Uv/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
@@ -1976,9 +1964,8 @@ checksums:
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -2196,26 +2183,17 @@ checksums:
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
GXGbXi/translation: 8686cb61f9bd3cd7ed100cc3af819458
GXGbXi/message: 8686cb61f9bd3cd7ed100cc3af819458
GXGbXi/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
8AbRER/message: 3cb81c4829857556f5d117c9a23f7bcc
8AbRER/origin/0/0: 81b173297e9c022d9ebde5f7018c95f9
8AbRER/origin/1/0: 0eeeb7e68164ff118040918f1ef00a7a
8AbRER/origin/2/0: 2201bf679c663a732d7f1b3c3ce3897f
8AbRER/translation: 3cb81c4829857556f5d117c9a23f7bcc
2gHjVM/translation: 873c4eb14a7e32e7fd7793047b6028a1
2gHjVM/message: 873c4eb14a7e32e7fd7793047b6028a1
2gHjVM/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/1/0: 627b72cd19a7154ae1c28cbd7ca0c730
2gHjVM/origin/2/0: 0627e0040ca4939c9a57349e98c51797
2gHjVM/origin/3/0: 0627e0040ca4939c9a57349e98c51797
Eit43O/message: b5d9a1edd988f455041aee9fa7fb3d63
Eit43O/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
Eit43O/translation: b5d9a1edd988f455041aee9fa7fb3d63
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
JjEJSy/message: 0154ff378b3f4329871378229e269fc7
JjEJSy/origin/0/0: 69d8634b177fdf937764b406fd477547
JjEJSy/translation: 0154ff378b3f4329871378229e269fc7
Tz0i8g/message: 8df6777277469c1fd88cc18dde2f1cc3
Tz0i8g/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
Tz0i8g/origin/1/0: 94d3e20c29bb517dd394711d7524e478
@@ -2250,10 +2228,10 @@ checksums:
n1ekoW/message: ec7b8f314fe9bc6591006707484ede61
n1ekoW/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
n1ekoW/translation: ec7b8f314fe9bc6591006707484ede61
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
fcWrnU/message: 6d842546a84e919c519bf8cbffdf25a0
fcWrnU/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
fcWrnU/origin/1/0: 69d8634b177fdf937764b406fd477547
fcWrnU/translation: 6d842546a84e919c519bf8cbffdf25a0
mErq7F/message: 0dd2ae69be4618c1f9e615774a4509ca
mErq7F/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
mErq7F/translation: 0dd2ae69be4618c1f9e615774a4509ca
@@ -2289,9 +2267,9 @@ checksums:
wIb7Ng/message: 67659097ef6bcb194839858b2c4f3a3c
wIb7Ng/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
wIb7Ng/translation: 67659097ef6bcb194839858b2c4f3a3c
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
6sKjjx/message: 50861c0881fe87a77412417e5ffb3fbb
6sKjjx/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
6sKjjx/translation: 50861c0881fe87a77412417e5ffb3fbb
vnS6Rf/message: 003f7f93d44b0f5c2e40bf24e7af3bd7
vnS6Rf/origin/0/0: c0b59968b2b234167c488a05cc38710b
vnS6Rf/translation: 003f7f93d44b0f5c2e40bf24e7af3bd7
@@ -2331,9 +2309,9 @@ checksums:
D%2BNlUC/message: 803281c327a02a2feba182f4a2b38311
D%2BNlUC/origin/0/0: 358ea1b5babb2e9ccbf1a4c9f89dd3a5
D%2BNlUC/translation: 803281c327a02a2feba182f4a2b38311
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
KM6m8p/message: c621ea9404a37af289def443b309bf1b
KM6m8p/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
KM6m8p/translation: c621ea9404a37af289def443b309bf1b
bff61F/message: 3f0ae9c914f16ceb9a0d7db868f4aa20
bff61F/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
bff61F/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
@@ -2369,14 +2347,6 @@ checksums:
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
InJcdo/message: 1b2364c95fd318d99cc502745beac94a
InJcdo/origin/0/0: f64cd62b620f454998523af2a4330278
InJcdo/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
InJcdo/translation: 1b2364c95fd318d99cc502745beac94a
zDNL2V/message: 6e053a87e63372e903c16a06de25c9a3
zDNL2V/origin/0/0: f64cd62b620f454998523af2a4330278
zDNL2V/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
zDNL2V/translation: 6e053a87e63372e903c16a06de25c9a3
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
@@ -2447,12 +2417,12 @@ checksums:
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
@@ -2471,9 +2441,6 @@ checksums:
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
@@ -2521,9 +2488,6 @@ checksums:
LeM5RY/message: dfeac280858332082ad34d2623a59863
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
LeM5RY/translation: dfeac280858332082ad34d2623a59863
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
@@ -2543,9 +2507,6 @@ checksums:
YcyP2z/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
YcyP2z/translation: 7495f1434c9188849bffd015811d5337
c1vOQR/message: 640ab5162eed616a7235d68376a016ed
c1vOQR/origin/0/0: f64cd62b620f454998523af2a4330278
c1vOQR/translation: 640ab5162eed616a7235d68376a016ed
MxQXhL/message: c1c949477e121d73ed6683eddf9cd874
MxQXhL/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
MxQXhL/origin/1/0: 69d8634b177fdf937764b406fd477547
@@ -2565,9 +2526,6 @@ checksums:
2QGEbi/message: 550198b2c87f06726a843c79c1026ed9
2QGEbi/origin/0/0: 4b4f55c033add8db69178563832227c6
2QGEbi/translation: 550198b2c87f06726a843c79c1026ed9
23nvb2/message: b9dedce59b5594ee2a88bf5d55d6dfac
23nvb2/origin/0/0: a1fde3fcce608870830f168f027727ff
23nvb2/translation: b9dedce59b5594ee2a88bf5d55d6dfac
Oo4E6p/message: af985cf72c47df76e2f2139a427d9948
Oo4E6p/origin/0/0: 97bd206a05414e2a571020d64e166093
Oo4E6p/translation: af985cf72c47df76e2f2139a427d9948
@@ -2586,13 +2544,9 @@ checksums:
A8jqN7/message: 656c93265d7e2bef1245b17d85f85ae5
A8jqN7/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
A8jqN7/translation: 656c93265d7e2bef1245b17d85f85ae5
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
Q60WUZ/message: 4284c33aed2f2c5e004ed0a7d7d9bf87
Q60WUZ/origin/0/0: 69d8634b177fdf937764b406fd477547
Q60WUZ/translation: 4284c33aed2f2c5e004ed0a7d7d9bf87
KNK33q/message: a606d59661b59b5e2e9ab58e8cbc143b
KNK33q/origin/0/0: f64cd62b620f454998523af2a4330278
KNK33q/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
KNK33q/translation: a606d59661b59b5e2e9ab58e8cbc143b
T7Mywq/message: 62899897d7bffcb1df3af77d460f0881
T7Mywq/origin/0/0: 0ad2af981579a62b084b3d41462c5546
T7Mywq/translation: 62899897d7bffcb1df3af77d460f0881
@@ -2635,9 +2589,6 @@ checksums:
TFv5tM/message: 4f2240aeff6f7275feb33ca3f608e48c
TFv5tM/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
TFv5tM/translation: 4f2240aeff6f7275feb33ca3f608e48c
V1o9Jo/message: 7b212f1c2fdc370974f859b08349b0c6
V1o9Jo/origin/0/0: f64cd62b620f454998523af2a4330278
V1o9Jo/translation: 7b212f1c2fdc370974f859b08349b0c6
It4%2FFS/message: ae1527c2765592f13980acb66997e570
It4%2FFS/origin/0/0: 0ad2af981579a62b084b3d41462c5546
It4%2FFS/translation: ae1527c2765592f13980acb66997e570
@@ -2682,14 +2633,15 @@ checksums:
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/3/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/translation: f2949ca2dc18b0063af92e60779abb65
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/message: acb1fdc14ed875815ae9f8a5254bc634
8AwlaR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
8AwlaR/translation: acb1fdc14ed875815ae9f8a5254bc634
Ws%2B3X%2B/message: 905b10eeb09fdc5ac7fc757e935ee757
Ws%2B3X%2B/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
Ws%2B3X%2B/translation: 905b10eeb09fdc5ac7fc757e935ee757
@@ -2729,7 +2681,8 @@ checksums:
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
cWtfn1/origin/1/0: 69d8634b177fdf937764b406fd477547
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
@@ -2802,15 +2755,9 @@ checksums:
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
an6ayw/message: 2d39f6440ad379e417193dbfaa265b5f
an6ayw/origin/0/0: f64cd62b620f454998523af2a4330278
an6ayw/translation: 2d39f6440ad379e417193dbfaa265b5f
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
LnaC5y/message: 229d2a9159e17f243cf317b9bd7d471c
LnaC5y/origin/0/0: a1fde3fcce608870830f168f027727ff
LnaC5y/translation: 229d2a9159e17f243cf317b9bd7d471c
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
@@ -2824,9 +2771,6 @@ checksums:
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
kxZ7LS/message: 41ebcdff41ce85329915754faf7140c0
kxZ7LS/origin/0/0: f64cd62b620f454998523af2a4330278
kxZ7LS/translation: 41ebcdff41ce85329915754faf7140c0
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
@@ -2933,9 +2877,9 @@ checksums:
RtlIYB/message: df4783d593505766d78c5bc08d3d48ac
RtlIYB/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
RtlIYB/translation: df4783d593505766d78c5bc08d3d48ac
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
3WXdoZ/message: 4cbeab40f77975492a23eec428732440
3WXdoZ/origin/0/0: 69d8634b177fdf937764b406fd477547
3WXdoZ/translation: 4cbeab40f77975492a23eec428732440
aelko%2B/message: 41dd145ef56f539e12dc064a9807c660
aelko%2B/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
aelko%2B/translation: 41dd145ef56f539e12dc064a9807c660
@@ -2970,24 +2914,18 @@ checksums:
MdN5zD/message: e2e816f2b7a13b1056d79e7f25088664
MdN5zD/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
MdN5zD/translation: e2e816f2b7a13b1056d79e7f25088664
ZhQoZn/translation: b05a1191bf048d76e7d6c1c3e76ce1fd
ZhQoZn/message: b05a1191bf048d76e7d6c1c3e76ce1fd
ZhQoZn/origin/0/0: 0627e0040ca4939c9a57349e98c51797
2SePRT/message: 257b840726f972f384243a72767f880f
2SePRT/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
2SePRT/translation: 257b840726f972f384243a72767f880f
uOqaMC/message: 24fc6cdc8740f37a83df85f582f03293
uOqaMC/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
uOqaMC/translation: 24fc6cdc8740f37a83df85f582f03293
8Zjc7q/translation: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/message: 65609a209e74f230563a7c8d7258a83c
8Zjc7q/origin/0/0: 278a40eb45b1219f424703d0abc1d273
GoDLB9/message: 8c8d944e07388c5877effdb2c2803dcf
GoDLB9/origin/0/0: 5c60161e173f5eae9276d8e4c70b4ebb
GoDLB9/translation: 8c8d944e07388c5877effdb2c2803dcf
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
c4nMcR/message: bf4f697079eef51c3ebe46ce79063895
c4nMcR/origin/0/0: 15ea2dfd5eae4e05b226b1320103476e
c4nMcR/translation: bf4f697079eef51c3ebe46ce79063895
evg7%2BA/message: 403972e7a25afc2415762c1c2b1ec868
evg7%2BA/origin/0/0: 3c57a918258af4b44c187f58d203345f
evg7%2BA/translation: 403972e7a25afc2415762c1c2b1ec868
@@ -3009,9 +2947,6 @@ checksums:
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
EuK5ez/translation: d262a1ba9ebeea549d982f9cf85a6c18
EuK5ez/message: d262a1ba9ebeea549d982f9cf85a6c18
EuK5ez/origin/0/0: 0627e0040ca4939c9a57349e98c51797
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
@@ -3027,12 +2962,12 @@ checksums:
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
@@ -3045,10 +2980,10 @@ checksums:
Hg1qE9/message: c808949b9b2b4a9aba2472f5d1050167
Hg1qE9/origin/0/0: 30791e26a172d47d3cb0693e82679246
Hg1qE9/translation: c808949b9b2b4a9aba2472f5d1050167
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
OTxjpR/message: 370a32c49489db61978ac91b55699c24
OTxjpR/origin/0/0: 69d8634b177fdf937764b406fd477547
OTxjpR/origin/1/0: 69d8634b177fdf937764b406fd477547
OTxjpR/translation: 370a32c49489db61978ac91b55699c24
4DOCMI/message: 0b48896061a1124501fdaba026804148
4DOCMI/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
4DOCMI/translation: 0b48896061a1124501fdaba026804148

View File

@@ -33,7 +33,6 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -160,17 +159,8 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" &&
result.cardNumber != null &&
workspace.cardPrefix && (
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
{workspace.cardPrefix}-{result.cardNumber}
</span>
)}
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
</div>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

@@ -1,7 +1,6 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect, useRef, useState } from "react";
import {
TbLayoutSidebarLeftCollapse,
@@ -16,9 +15,6 @@ import { useClickOutside } from "~/hooks/useClickOutside";
import { useModal } from "~/providers/modal";
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
import { api } from "~/utils/api";
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
import Button from "./Button";
import Modal from "./modal";
import SideNavigation from "./SideNavigation";
interface DashboardProps {
@@ -47,7 +43,7 @@ export default function Dashboard({
hasRightPanel = false,
}: DashboardProps) {
const { resolvedTheme } = useTheme();
const { openModal, closeModal, modalContentType } = useModal();
const { openModal } = useModal();
const { availableWorkspaces, hasLoaded } = useWorkspace();
const router = useRouter();
@@ -106,32 +102,13 @@ export default function Dashboard({
useEffect(() => {
if (hasLoaded && availableWorkspaces.length === 0) {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
router.push(
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
);
router.push(`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`);
} else {
openModal("NEW_WORKSPACE", undefined, undefined, false);
}
}
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
useEffect(() => {
const isCredentialsEnabled =
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
if (
!userLoading &&
user &&
isCredentialsEnabled &&
user.hasMagicLinkAccount &&
!user.hasPassword &&
!sessionStorage.getItem("set_password_prompted")
) {
sessionStorage.setItem("set_password_prompted", "1");
openModal("SET_PASSWORD_PROMPT");
}
}, [user, userLoading, openModal]);
const isDarkMode = resolvedTheme === "dark";
return (
@@ -226,30 +203,6 @@ export default function Dashboard({
</div>
</div>
</div>
<Modal
modalSize="sm"
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
>
{user?.hasPassword ? (
<div className="p-5">
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
<p className="mb-6 text-sm text-light-900">
{t`Your account already has a password. You can change it from your account settings.`}
</p>
<Button
variant="secondary"
onClick={closeModal}
fullWidth
size="lg"
>
{t`Close`}
</Button>
</div>
) : (
<ChangePasswordFormConfirmation hasPassword={false} />
)}
</Modal>
</>
);
}

View File

@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
export interface WorkspaceMember {
publicId: string;
user: {
id: string | null;
id: string;
name: string | null;
image: string | null;
} | null;

View File

@@ -1,6 +1,6 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
ptbr: ptBR,
"pt-BR": ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +0,0 @@
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
export interface TierConfig {
plan: WorkspacePlan;
seats: number | null;
unlimitedSeats: boolean;
}
const TIER_MAP: Record<number, TierConfig> = {
1: { plan: "team", seats: 5, unlimitedSeats: false },
2: { plan: "pro", seats: 15, unlimitedSeats: false },
3: { plan: "pro", seats: null, unlimitedSeats: true },
};
export function tierConfig(tier: number): TierConfig {
const config = TIER_MAP[tier];
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
return config;
}

View File

@@ -1,168 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
const log = createLogger("api");
interface TokenResponse {
access_token: string;
token_type: string;
}
interface OAuthLicenseResponse {
license_key: string;
status: string;
}
interface LicenseDetailResponse {
license_key: string;
status: string;
tier: number;
}
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
const tokenUrl = process.env.PARTNER_TOKEN_URL;
const clientId = process.env.PARTNER_CLIENT_ID;
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUrl,
}),
});
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
return res.json() as Promise<TokenResponse>;
}
async function fetchOAuthLicense(
accessToken: string,
): Promise<OAuthLicenseResponse> {
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
if (!oauthLicenseUrl)
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
return res.json() as Promise<OAuthLicenseResponse>;
}
async function fetchLicenseDetail(
licenseKey: string,
): Promise<LicenseDetailResponse> {
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
const apiKey = process.env.PARTNER_API_KEY;
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
headers: { [apiKeyHeader]: apiKey },
});
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
return res.json() as Promise<LicenseDetailResponse>;
}
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { code } = req.query;
if (!code || typeof code !== "string") {
return res.status(400).json({ message: "Missing code parameter" });
}
let license: LicenseDetailResponse;
try {
const tokenData = await exchangeCodeForToken(code);
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
license = await fetchLicenseDetail(oauthLicense.license_key);
} catch (err) {
log.error({ err }, "Partner OAuth flow failed");
return res.redirect(`/partner/activate?error=oauth_failed`);
}
const { db, user } = await createNextApiContext(req);
const cfg = tierConfig(license.tier);
const isActive = license.status === "active";
const status = isActive ? "active" : "inactive";
if (!user) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
);
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
await subscriptionRepo.upsertByPartnerLicenseKey(
db,
license.license_key,
{
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
},
);
return res.redirect(
`/onboarding?license_key=${encodeURIComponent(license.license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license.license_key, {
plan: cfg.plan,
status,
partnerTier: license.tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: workspace.publicId,
});
if (isActive) {
await workspaceRepo.update(db, workspace.publicId, { plan: cfg.plan });
}
return res.redirect(`/?partner_activated=1`);
}),
);

View File

@@ -1,64 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import { withRateLimit } from "@kan/api/utils/rateLimit";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
export default withRateLimit(
{ points: 20, duration: 60 },
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json({ message: "Method not allowed" });
}
const { license_key } = req.query;
if (!license_key || typeof license_key !== "string") {
return res.redirect("/?partner_error=missing_license");
}
const { db, user } = await createNextApiContext(req);
if (!user) {
return res.redirect(
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
);
}
const sub = await subscriptionRepo.getByPartnerLicenseKey(db, license_key);
if (!sub) {
return res.redirect("/?partner_error=invalid_license");
}
if (sub.status !== "active") {
return res.redirect("/?partner_error=license_inactive");
}
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
const workspace = memberships?.[0]?.workspace;
if (!workspace) {
return res.redirect(
`/onboarding?license_key=${encodeURIComponent(license_key)}`,
);
}
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: sub.plan,
status: sub.status,
partnerTier: sub.partnerTier ?? 1,
seats: sub.seats ?? null,
unlimitedSeats: sub.unlimitedSeats,
referenceId: workspace.publicId,
});
await workspaceRepo.update(db, workspace.publicId, {
plan: sub.plan as "free" | "team" | "pro" | "enterprise",
});
return res.redirect("/?partner_activated=1");
}),
);

View File

@@ -1,192 +0,0 @@
import { createHmac, timingSafeEqual } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import { withApiLogging } from "@kan/api/utils/apiLogging";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { tierConfig } from "./_utils";
const log = createLogger("api");
async function buffer(readable: Readable) {
const chunks: Buffer[] = [];
for await (const chunk of readable) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}
function verifySignature(
rawBody: string,
signature: string,
timestamp: string,
): boolean {
const secret = process.env.PARTNER_API_KEY;
if (!secret) return false;
const ts = Number(timestamp);
if (isNaN(ts) || Date.now() / 1000 - ts > 300) return false;
const payload = `${timestamp}.${rawBody}`;
const expected = createHmac("sha256", secret).update(payload).digest("hex");
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
interface WebhookPayload {
event:
| "purchase"
| "activate"
| "deactivate"
| "upgrade"
| "downgrade"
| "migrate";
license_key: string;
license_status: string;
tier: number;
prev_license_key?: string;
}
export default withApiLogging(
async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
if (!signatureHeader || !timestampHeader) {
log.error("Webhook signature headers not configured");
return res.status(500).json({ message: "Server misconfiguration" });
}
const signature = req.headers[signatureHeader] as string | undefined;
const timestamp = req.headers[timestampHeader] as string | undefined;
if (!signature || !timestamp) {
return res.status(400).json({ message: "Missing signature headers" });
}
const buf = await buffer(req as unknown as Readable);
const rawBody = buf.toString("utf8");
if (!verifySignature(rawBody, signature, timestamp)) {
return res.status(401).json({ message: "Invalid signature" });
}
const payload = JSON.parse(rawBody) as WebhookPayload;
const { event, license_key, license_status, tier, prev_license_key } =
payload;
const { db } = await createNextApiContext(req);
switch (event) {
case "purchase": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: license_status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "activate": {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: "active",
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
break;
}
case "deactivate": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
await subscriptionRepo.updateById(db, sub.id, {
plan: "free",
status: "inactive",
});
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: "free" });
}
}
break;
}
case "upgrade":
case "downgrade": {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: license_status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
});
if (sub.referenceId) {
await workspaceRepo.update(db, sub.referenceId, { plan: cfg.plan });
}
}
break;
}
case "migrate": {
if (prev_license_key) {
const sub = await subscriptionRepo.getByPartnerLicenseKey(
db,
prev_license_key,
);
if (sub) {
const cfg = tierConfig(tier);
await subscriptionRepo.upsertByPartnerLicenseKey(db, license_key, {
plan: cfg.plan,
status: sub.status,
partnerTier: tier,
seats: cfg.seats,
unlimitedSeats: cfg.unlimitedSeats,
referenceId: sub.referenceId ?? undefined,
});
await subscriptionRepo.updateById(db, sub.id, {
status: "inactive",
});
}
}
break;
}
default:
log.warn({ event }, "Unhandled partner webhook event");
}
return res.status(200).json({ success: true, event });
},
);
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -1,98 +0,0 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useEffect, useState } from "react";
import { authClient } from "@kan/auth/client";
import { Auth } from "~/components/AuthForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
export default function PartnerActivatePage() {
const router = useRouter();
const searchParams = useSearchParams();
const licenseKey = searchParams.get("license_key");
const error = searchParams.get("error");
const { data: session, isPending } = authClient.useSession();
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
useEffect(() => {
if (!isPending && session?.user && licenseKey) {
router.push(
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
);
} else if (!isPending && session?.user && !licenseKey) {
router.push("/boards");
}
}, [session, isPending, licenseKey, router]);
if (isPending || (session?.user && licenseKey)) return null;
return (
<>
<PageHead title={t`Activate | kan.bn`} />
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
<div className="z-10 flex w-full flex-col items-center">
<Link href="/">
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
kan.bn
</h1>
</Link>
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
</p>
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
{isMagicLinkSent ? (
<Trans>We sent a link to {magicLinkRecipient}</Trans>
) : (
t`Sign in or create an account to activate your license`
)}
</p>
{error && (
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
{t`Something went wrong during activation. Please try again.`}
</div>
)}
{!isMagicLinkSent && (
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<Auth
setIsMagicLinkSent={(val, recipient) => {
setIsMagicLinkSent(val);
setMagicLinkRecipient(recipient);
}}
/>
</div>
</div>
)}
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans>
Already have an account?{" "}
<span className="underline">
<Link
href={
licenseKey
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
: "/login"
}
>
Sign in
</Link>
</span>
</Trans>
</p>
</div>
<PatternedBackground />
</div>
</main>
</>
);
}

View File

@@ -20,7 +20,6 @@ interface Workspace {
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -31,7 +30,6 @@ const initialWorkspace: Workspace = {
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -92,7 +90,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -124,7 +121,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
cardPrefix: selectedWorkspace.workspace.cardPrefix,
});
if (workspacePublicId) {
@@ -145,7 +141,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: primaryWorkspace.description,
role: primaryWorkspaceRole,
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
cardPrefix: primaryWorkspace.cardPrefix,
});
}
}, [data, isLoading, workspacePublicId, router]);

View File

@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
case "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,4 +1,3 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
@@ -30,7 +29,6 @@ export default function BoardDropdown({
isFavorite?: boolean;
boardName?: string;
}) {
const router = useRouter();
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
@@ -49,7 +47,6 @@ export default function BoardDropdown({
: t`The board has been unarchived.`,
icon: "success",
});
void router.push(`/boards`);
} else if (variables.favorite !== undefined) {
showPopup({
header: variables.favorite

View File

@@ -16,7 +16,6 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -26,7 +25,6 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -69,11 +67,6 @@ const Card = ({
return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
{ticketNumber && (
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
{ticketNumber}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||

View File

@@ -146,10 +146,6 @@ export function NewCardForm({
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
cardNumber: null,
comments: [],
checklists: [],
attachments: [],
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -162,9 +158,6 @@ export function NewCardForm({
...member,
deletedAt: null,
})) ?? [],
comments: [],
checklists: [],
attachments: [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,

View File

@@ -2,9 +2,9 @@ import type { DropResult } from "react-beautiful-dnd";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRouter } from "next/router";
import { env } from "next-runtime-env";
import { t } from "@lingui/core/macro";
import { keepPreviousData } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { useForm } from "react-hook-form";
@@ -757,11 +757,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}

View File

@@ -525,6 +525,7 @@ const ActivityList = ({
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
);

View File

@@ -1,7 +1,6 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -19,13 +18,11 @@ export default function CardDropdown({
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
@@ -56,40 +53,12 @@ export default function CardDropdown({
}
};
const handleCopyTicketId = async () => {
if (!ticketNumber) return;
try {
await navigator.clipboard.writeText(ticketNumber);
showPopup({
header: t`ID copied`,
icon: "success",
message: t`Ticket ID copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy ID`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(ticketNumber
? [
{
label: t`Copy ticket ID`,
action: handleCopyTicketId,
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(canEditCard
? [
{

View File

@@ -340,14 +340,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -355,11 +347,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -140,11 +140,6 @@ export function CardModal({
</div>
) : (
<>
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
</span>
)}
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>

View File

@@ -85,19 +85,17 @@ export default function AccountSettings() {
{isCredentialsEnabled && (
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
{data?.hasPassword ? t`Change Password` : t`Set Password`}
{t`Change Password`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{data?.hasPassword
? t`You are about to change your password.`
: t`Set a password to enable password-based login.`}
{t`You are about to change your password.`}
</p>
<div className="mt-4">
<Button
variant="secondary"
onClick={() => openModal("CHANGE_PASSWORD")}
>
{data?.hasPassword ? t`Change Password` : t`Set Password`}
{t`Change Password`}
</Button>
</div>
</div>
@@ -115,7 +113,7 @@ export default function AccountSettings() {
modalSize="sm"
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
>
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
<ChangePasswordFormConfirmation />
</Modal>
{/* Global modals */}

View File

@@ -17,11 +17,11 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
export default function WorkspaceSettings() {

View File

@@ -2,6 +2,7 @@ import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { env } from "next-runtime-env";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -13,43 +14,27 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const buildSchema = (hasPassword: boolean) => {
const base = z.object({
currentPassword: hasPassword
? z.string().min(1, t`Current password is required`)
: z.string().optional(),
const FormSchema = z
.object({
currentPassword: z.string().min(1, t`Current password is required`),
newPassword: z
.string()
.min(8, t`Password must be at least 8 characters`)
.min(1, t`New password is required`),
confirmPassword: z.string().min(1, t`Please confirm your new password`),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: t`New password must be different from current password`,
path: ["newPassword"],
});
return base
.refine((data) => data.newPassword === data.confirmPassword, {
message: t`Passwords do not match`,
path: ["confirmPassword"],
})
.refine(
(data) => !hasPassword || data.currentPassword !== data.newPassword,
{
message: t`New password must be different from current password`,
path: ["newPassword"],
},
);
};
type FormValues = z.infer<typeof FormSchema>;
interface FormValues {
currentPassword?: string;
newPassword: string;
confirmPassword: string;
}
interface Props {
hasPassword: boolean;
}
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
export function ChangePasswordFormConfirmation() {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const router = useRouter();
@@ -62,24 +47,15 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
reset,
setError,
} = useForm<FormValues>({
resolver: zodResolver(buildSchema(hasPassword)),
resolver: zodResolver(FormSchema),
mode: "onChange",
});
const setPasswordMutation = api.user.setPassword.useMutation();
const changePasswordMutation = useMutation({
mutationFn: async (data: FormValues) => {
if (!hasPassword) {
await setPasswordMutation.mutateAsync({
newPassword: data.newPassword,
});
return;
}
const response = await authClient.changePassword({
newPassword: data.newPassword,
currentPassword: data.currentPassword ?? "",
currentPassword: data.currentPassword,
revokeOtherSessions: true,
});
@@ -90,18 +66,12 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
onSuccess: async () => {
closeModal();
showPopup({
header: hasPassword ? t`Password Changed` : t`Password Set`,
message: hasPassword
? t`Your password has been changed.`
: t`Your password has been set.`,
header: t`Password Changed`,
message: t`Your password has been changed.`,
icon: "success",
});
if (!hasPassword) {
sessionStorage.removeItem("set_password_prompted");
}
utils.user.getUser.invalidate();
utils.invalidate();
reset();
router.push("/");
},
@@ -116,9 +86,7 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
} else {
closeModal();
showPopup({
header: hasPassword
? t`Error Changing Password`
: t`Error Setting Password`,
header: t`Error Changing Password`,
message: t`An unexpected error occurred. Please try again later.`,
icon: "error",
});
@@ -138,33 +106,27 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="pb-4 text-base font-medium dark:text-white">
{hasPassword ? t`Change Password` : t`Set Password`}
</h2>
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
<p className="mb-4 text-sm text-light-900">
{hasPassword
? t`Enter your current password and choose a new secure password.`
: t`You signed in without a password. Set a password to enable password-based login.`}
{t`Enter your current password and choose a new secure password.`}
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
{hasPassword && (
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
)}
<div>
<Input
id="currentPassword"
type="password"
{...register("currentPassword")}
placeholder={t`Enter your current password`}
/>
{errors.currentPassword && (
<p className="mt-2 text-xs text-red-400">
{errors.currentPassword.message}
</p>
)}
</div>
<div>
<Input
@@ -213,7 +175,7 @@ export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
fullWidth
size="lg"
>
{hasPassword ? t`Change Password` : t`Set Password`}
{t`Change Password`}
</Button>
</div>
</form>

View File

@@ -13,29 +13,20 @@ interface DeleteWebhookConfirmationProps {
export function DeleteWebhookConfirmation({
workspacePublicId,
}: DeleteWebhookConfirmationProps) {
const {
closeModal,
entityId: webhookPublicId,
entityLabel: webhookName,
} = useModal();
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const deleteWebhookMutation = api.webhook.delete.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({
header: t`Webhook deleted`,
message: t`Webhook deleted successfully`,
icon: "success",
});
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to delete webhook`,
message: error.message || t`Failed to delete webhook`,
icon: "error",
type: "error",
});
},
});
@@ -44,7 +35,7 @@ export function DeleteWebhookConfirmation({
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId,
webhookPublicId: webhookPublicId as string,
});
};

View File

@@ -44,12 +44,7 @@ export function NewWebhookModal({
workspacePublicId,
isEdit = false,
}: NewWebhookModalProps) {
const {
closeModal,
entityId: webhookPublicId,
getModalState,
clearModalState,
} = useModal();
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
const { showPopup } = usePopup();
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
@@ -106,18 +101,13 @@ export function NewWebhookModal({
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({
header: t`Webhook created`,
message: t`Webhook created successfully`,
icon: "success",
});
showPopup({ message: t`Webhook created successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to create webhook`,
message: error.message || t`Failed to create webhook`,
icon: "error",
type: "error",
});
},
});
@@ -125,18 +115,13 @@ export function NewWebhookModal({
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({
header: t`Webhook updated`,
message: t`Webhook updated successfully`,
icon: "success",
});
showPopup({ message: t`Webhook updated successfully`, type: "success" });
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to update webhook`,
message: error.message || t`Failed to update webhook`,
icon: "error",
type: "error",
});
},
});
@@ -144,25 +129,19 @@ export function NewWebhookModal({
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({
header: t`Test sent`,
message: t`Test webhook sent successfully!`,
icon: "success",
});
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
icon: "error",
type: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
icon: "error",
type: "error",
});
setIsTestingWebhook(false);
},
@@ -172,7 +151,7 @@ export function NewWebhookModal({
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId,
webhookPublicId: webhookPublicId as string,
name: data.name,
url: data.url,
secret: data.secret || undefined,
@@ -195,12 +174,11 @@ export function NewWebhookModal({
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId,
webhookPublicId: webhookPublicId as string,
});
};
const isPending =
createWebhookMutation.isPending || updateWebhookMutation.isPending;
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
@@ -253,11 +231,7 @@ export function NewWebhookModal({
<Input
id="secret"
type="password"
placeholder={
isEdit
? t`Enter new secret to update`
: t`HMAC secret for signature verification`
}
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
{...register("secret")}
errorMessage={errors.secret?.message}
/>
@@ -278,7 +252,7 @@ export function NewWebhookModal({
{webhookEvents.map((event) => (
<label
key={event}
className="flex cursor-pointer items-center space-x-2"
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
@@ -288,11 +262,11 @@ export function NewWebhookModal({
field.onChange([...field.value, event]);
} else {
field.onChange(
field.value.filter((v) => v !== event),
field.value.filter((v) => v !== event)
);
}
}}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
@@ -303,19 +277,17 @@ export function NewWebhookModal({
)}
/>
{errors.events && (
<p className="mt-1 text-xs text-red-500">
{errors.events.message}
</p>
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
)}
</div>
{isEdit && (
<div>
<label className="flex cursor-pointer items-center space-x-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
{...register("active")}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{t`Active`}

View File

@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
setValue(newValue);
updateWorkspace.mutate({
workspacePublicId,
weekStartDay: newValue as 0 | 1 | 6,
weekStartDay: newValue,
});
};

View File

@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
}: {
workspacePublicId: string;
workspaceUrl: string;
workspacePlan: "free" | "team" | "pro" | "enterprise";
workspacePlan: "free" | "pro" | "enterprise";
disabled?: boolean;
}) => {
const utils = api.useUtils();

View File

@@ -171,20 +171,18 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
icon: "error",
type: "error",
});
}
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
icon: "error",
type: "error",
});
},
});

View File

@@ -61,7 +61,7 @@ services:
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
- NEXT_PUBLIC_STORAGE_URL
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}

View File

@@ -8,7 +8,6 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { attachmentConfirmResponseSchema } from "../schemas";
import { assertPermission } from "../utils/permissions";
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
@@ -111,7 +110,7 @@ export const attachmentRouter = createTRPCRouter({
size: z.number().positive(),
}),
)
.output(attachmentConfirmResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,13 +16,6 @@ import {
} from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
boardListItemSchema,
boardDetailSchema,
boardBySlugSchema,
boardCreateResponseSchema,
boardUpdateResponseSchema,
} from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const boardRouter = createTRPCRouter({
@@ -44,7 +37,9 @@ export const boardRouter = createTRPCRouter({
archived: z.boolean().optional(),
}),
)
.output(z.array(boardListItemSchema))
.output(
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -111,7 +106,7 @@ export const boardRouter = createTRPCRouter({
type: z.enum(["regular", "template"]).optional(),
}),
)
.output(boardDetailSchema)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -251,7 +246,7 @@ export const boardRouter = createTRPCRouter({
.optional(),
}),
)
.output(boardBySlugSchema.nullable())
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
.query(async ({ ctx, input }) => {
const workspace = await workspaceRepo.getBySlugWithBoards(
ctx.db,
@@ -304,7 +299,7 @@ export const boardRouter = createTRPCRouter({
sourceBoardPublicId: z.string().min(12).optional(),
}),
)
.output(boardCreateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -474,7 +469,7 @@ export const boardRouter = createTRPCRouter({
isArchived: z.boolean().optional(),
}),
)
.output(boardUpdateResponseSchema)
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -10,14 +10,6 @@ import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
} from "../schemas";
import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
@@ -50,7 +42,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(cardCreateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -78,7 +70,6 @@ export const cardRouter = createTRPCRouter({
description: input.description,
createdBy: userId,
listId: list.id,
workspaceId: list.workspaceId,
position: input.position,
dueDate: input.dueDate ?? null,
});
@@ -187,11 +178,10 @@ export const cardRouter = createTRPCRouter({
"card.created",
{
id: String(newCard.id),
publicId: newCard.publicId,
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: list.publicId,
listId: String(newCard.listId),
},
{
boardId: list.boardPublicId,
@@ -225,7 +215,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(commentResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -298,7 +288,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(commentResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -386,7 +376,7 @@ export const cardRouter = createTRPCRouter({
commentPublicId: z.string().min(12),
}),
)
.output(commentDeleteResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -445,7 +435,7 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
});
return { publicId: input.commentPublicId };
return deletedComment;
}),
addOrRemoveLabel: protectedProcedure
.meta({
@@ -649,7 +639,25 @@ export const cardRouter = createTRPCRouter({
},
})
.input(z.object({ cardPublicId: z.string().min(12) }))
.output(cardDetailSchema)
.output(
z.custom<
Omit<
NonNullable<
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
>,
"attachments"
> & {
attachments: {
publicId: string;
contentType: string;
s3Key: string;
originalFilename: string | null;
size?: number | null;
url: string | null;
}[];
}
>(),
)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
@@ -755,7 +763,15 @@ export const cardRouter = createTRPCRouter({
)
.output(
z.object({
activities: z.array(activityItemSchema),
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
@@ -855,7 +871,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(cardUpdateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -890,18 +906,9 @@ export const cardRouter = createTRPCRouter({
);
let newListId: number | undefined;
let newList:
| {
id: number;
publicId: string;
name: string;
boardId: number;
index: number;
}
| undefined;
if (input.listPublicId) {
newList = await listRepo.getByPublicId(
const newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
@@ -946,7 +953,7 @@ export const cardRouter = createTRPCRouter({
);
}
if (input.index !== undefined || newListId !== undefined) {
if (input.index !== undefined) {
result = await cardRepo.reorder(ctx.db, {
cardId: existingCard.id,
newIndex: input.index,
@@ -1048,19 +1055,8 @@ export const cardRouter = createTRPCRouter({
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
const currentWebhookListPublicId = movedToNewList
? input.listPublicId!
: existingCard.list.publicId;
const currentWebhookListName = movedToNewList
? newList?.name ?? card.listName
: existingCard.list.name;
if (movedToNewList) {
webhookChanges.listId = {
from: existingCard.list.publicId,
to: input.listPublicId!,
};
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
}
// Fire webhooks (non-blocking)
@@ -1068,19 +1064,20 @@ export const cardRouter = createTRPCRouter({
ctx.db,
card.workspaceId,
createCardWebhookPayload(
movedToNewList ? "card.moved" : "card.updated",
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
{
id: String(result.id),
publicId: result.publicId,
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: currentWebhookListPublicId,
listId: String(newListId ?? existingCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: currentWebhookListName,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1167,16 +1164,15 @@ export const cardRouter = createTRPCRouter({
"card.deleted",
{
id: String(fullCard.id),
publicId: fullCard.publicId,
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: fullCard.list.publicId,
listId: String(fullCard.listId),
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: fullCard.list.name,
listName: card.listName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1276,7 +1272,6 @@ export const cardRouter = createTRPCRouter({
description: sourceCard.description ?? "",
createdBy: userId,
listId: targetList.id,
workspaceId: targetList.workspaceId,
position: "end",
dueDate: sourceCard.dueDate ?? null,
});

View File

@@ -340,7 +340,6 @@ export const importRouter = createTRPCRouter({
description: card.description,
createdBy: userId,
listId: newListId,
workspaceId: workspace.id,
index,
importId: newImportId,
}));
@@ -889,7 +888,6 @@ export const importRouter = createTRPCRouter({
description: data.description,
createdBy: userId,
listId: data.listId,
workspaceId: workspace.id,
index: index,
importId: newImportId,
}));

View File

@@ -7,7 +7,6 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo";
import * as listRepo from "@kan/db/repository/list.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
export const listRouter = createTRPCRouter({
@@ -28,7 +27,7 @@ export const listRouter = createTRPCRouter({
boardPublicId: z.string().min(12),
}),
)
.output(listCreateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -164,7 +163,12 @@ export const listRouter = createTRPCRouter({
index: z.number().optional(),
}),
)
.output(listUpdateResponseSchema)
.output(
z.custom<
| Awaited<ReturnType<typeof listRepo.update>>
| Awaited<ReturnType<typeof listRepo.reorder>>
>(),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -16,7 +16,6 @@ import {
import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas";
import {
assertCanManageMember,
assertCanManageRole,
@@ -41,7 +40,7 @@ export const memberRouter = createTRPCRouter({
workspacePublicId: z.string().min(12),
}),
)
.output(memberInviteResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof memberRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

@@ -2,9 +2,9 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as userRepo from "@kan/db/repository/user.repo";
import { generateAvatarUrl } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { generateAvatarUrl } from "@kan/shared/utils";
export const userRouter = createTRPCRouter({
getUser: protectedProcedure
@@ -27,8 +27,6 @@ export const userRouter = createTRPCRouter({
name: z.string().nullable(),
image: z.string().nullable(),
stripeCustomerId: z.string().nullable(),
hasPassword: z.boolean(),
hasMagicLinkAccount: z.boolean(),
apiKey: z
.object({
id: z.number(),
@@ -63,8 +61,6 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
hasPassword: result.hasPassword,
hasMagicLinkAccount: result.hasMagicLinkAccount,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
};
}),
@@ -118,60 +114,4 @@ export const userRouter = createTRPCRouter({
image: imageUrl,
};
}),
setPassword: protectedProcedure
.meta({
openapi: {
method: "POST",
path: "/users/me/password",
summary: "Set password",
description:
"Sets a password for a user who signed up via magic link and has no password yet",
tags: ["Users"],
protect: true,
},
})
.input(
z.object({
newPassword: z
.string()
.min(8, "Password must be at least 8 characters"),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const existing = await userRepo.getById(ctx.db, userId);
if (!existing) {
throw new TRPCError({
message: `User not found`,
code: "NOT_FOUND",
});
}
if (existing.hasPassword) {
throw new TRPCError({
message: `Password already set; use change password instead`,
code: "BAD_REQUEST",
});
}
try {
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
} catch {
throw new TRPCError({
message: "Failed to set password",
code: "INTERNAL_SERVER_ERROR",
});
}
return { success: true };
}),
});

View File

@@ -334,7 +334,6 @@ export const webhookRouter = createTRPCRouter({
const testPayload = createCardWebhookPayload("card.created", {
id: "test-card-id",
publicId: "test-card-public-id",
title: "Test Card",
description: "This is a test webhook payload",
dueDate: null,

View File

@@ -7,14 +7,6 @@ import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
} from "../schemas";
import { assertPermission } from "../utils/permissions";
export const workspaceRouter = createTRPCRouter({
@@ -30,7 +22,9 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.void())
.output(z.array(workspaceListItemSchema))
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
)
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
@@ -56,7 +50,11 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(workspaceDetailSchema)
.output(
z.custom<
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
>(),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -136,7 +134,7 @@ export const workspaceRouter = createTRPCRouter({
return {
...result,
members: sanitizedMembers,
};
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
}
return {
@@ -164,7 +162,9 @@ export const workspaceRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(workspaceWithBoardsSchema)
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -211,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(workspaceCreateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
const userEmail = ctx.user?.email;
@@ -270,13 +270,7 @@ export const workspaceRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
return {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
};
return result;
}),
update: protectedProcedure
.meta({
@@ -306,7 +300,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(workspaceUpdateResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -389,7 +383,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(workspaceDeleteResponseSchema)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -411,12 +405,12 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
await workspaceRepo.hardDelete(
const result = await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
return { success: true };
return result;
}),
checkSlugAvailability: publicProcedure
.meta({
@@ -521,7 +515,6 @@ export const workspaceRouter = createTRPCRouter({
boardPublicId: z.string(),
boardName: z.string(),
listName: z.string(),
cardNumber: z.number().nullable(),
updatedAt: z.date().nullable(),
createdAt: z.date(),
type: z.literal("card"),

View File

@@ -1,12 +0,0 @@
import { z } from "zod";
// ─── attachment.confirm ──────────────────────────────────────
export const attachmentConfirmResponseSchema = z.object({
publicId: z.string(),
filename: z.string(),
originalFilename: z.string(),
contentType: z.string(),
size: z.number(),
s3Key: z.string(),
createdAt: z.date(),
});

View File

@@ -1,130 +0,0 @@
import { z } from "zod";
import {
checklistResponseSchema,
labelSchema,
workspaceMemberSchema,
} from "./common";
// ─── board.all ───────────────────────────────────────────────
export const boardListItemSchema = z.object({
publicId: z.string(),
name: z.string(),
favorite: z.boolean(),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
}),
),
labels: z.array(labelSchema),
});
// ─── Card sub-object inside board detail (byId) ─────────────
const boardCardMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
});
const boardDetailCardSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
labels: z.array(labelSchema),
members: z.array(boardCardMemberSchema),
attachments: z.array(z.object({ publicId: z.string() })),
checklists: z.array(checklistResponseSchema),
comments: z.array(z.object({ publicId: z.string() })),
});
// ─── board.byId ──────────────────────────────────────────────
export const boardDetailSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
visibility: z.string(),
isArchived: z.boolean(),
favorite: z.boolean(),
workspace: z.object({
publicId: z.string(),
members: z.array(workspaceMemberSchema),
}),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
cards: z.array(boardDetailCardSchema),
}),
),
allLists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
});
// ─── Card sub-object inside board detail (bySlug — no members) ─
const boardSlugCardSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
labels: z.array(labelSchema),
attachments: z.array(z.object({ publicId: z.string() })),
checklists: z.array(checklistResponseSchema),
comments: z.array(z.object({ publicId: z.string() })),
});
// ─── board.bySlug ────────────────────────────────────────────
export const boardBySlugSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
visibility: z.string(),
workspace: z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
}),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
cards: z.array(boardSlugCardSchema),
}),
),
allLists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
});
// ─── board.create / board.createFromSnapshot ─────────────────
export const boardCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});
// ─── board.update ────────────────────────────────────────────
export const boardUpdateResponseSchema = z.union([
z.object({ success: z.boolean() }),
z.object({ publicId: z.string(), name: z.string() }),
]);

View File

@@ -1,216 +0,0 @@
import { z } from "zod";
import {
checklistResponseSchema,
labelSchema,
workspaceMemberSchema,
} from "./common";
// ─── card.create ─────────────────────────────────────────────
export const cardCreateResponseSchema = z.object({
publicId: z.string(),
});
// ─── card.update ─────────────────────────────────────────────
export const cardUpdateResponseSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
dueDate: z.date().nullable(),
});
// ─── Comment responses ───────────────────────────────────────
export const commentResponseSchema = z.object({
publicId: z.string(),
comment: z.string(),
});
export const commentDeleteResponseSchema = z.object({
publicId: z.string(),
});
// ─── card.byId ───────────────────────────────────────────────
const cardMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
})
.nullable(),
});
export const cardDetailSchema = z.object({
publicId: z.string(),
title: z.string(),
description: z.string().nullable(),
dueDate: z.date().nullable(),
createdBy: z.string().nullable(),
labels: z.array(labelSchema),
attachments: z.array(
z.object({
publicId: z.string(),
contentType: z.string(),
s3Key: z.string(),
originalFilename: z.string().nullable(),
size: z.number().nullable(),
url: z.string().nullable(),
}),
),
checklists: z.array(checklistResponseSchema),
list: z.object({
publicId: z.string(),
name: z.string(),
board: z.object({
publicId: z.string(),
name: z.string(),
labels: z.array(labelSchema),
lists: z.array(
z.object({
publicId: z.string(),
name: z.string(),
}),
),
workspace: z.object({
publicId: z.string(),
members: z.array(workspaceMemberSchema),
}),
}),
}),
members: z.array(cardMemberSchema),
activities: z.array(
z.object({
publicId: z.string(),
type: z.string(),
createdAt: z.date(),
fromIndex: z.number().nullable(),
toIndex: z.number().nullable(),
fromTitle: z.string().nullable(),
toTitle: z.string().nullable(),
fromDescription: z.string().nullable(),
toDescription: z.string().nullable(),
fromDueDate: z.date().nullable(),
toDueDate: z.date().nullable(),
fromList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
toList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
label: z
.object({
publicId: z.string(),
name: z.string(),
})
.nullable(),
member: z
.object({
publicId: z.string(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
})
.nullable(),
})
.nullable(),
user: z
.object({
name: z.string().nullable(),
email: z.string(),
})
.nullable(),
comment: z
.object({
publicId: z.string(),
comment: z.string(),
createdBy: z.string().nullable(),
updatedAt: z.date().nullable(),
deletedAt: z.date().nullable(),
})
.nullable(),
}),
),
});
// ─── card.getActivities ──────────────────────────────────────
export const activityItemSchema = z.object({
publicId: z.string(),
type: z.string(),
createdAt: z.date(),
fromIndex: z.number().nullable(),
toIndex: z.number().nullable(),
fromTitle: z.string().nullable(),
toTitle: z.string().nullable(),
fromDescription: z.string().nullable(),
toDescription: z.string().nullable(),
fromDueDate: z.date().nullable(),
toDueDate: z.date().nullable(),
fromList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
toList: z
.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
})
.nullable(),
label: z
.object({
publicId: z.string(),
name: z.string(),
})
.nullable(),
member: z
.object({
publicId: z.string(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
})
.nullable(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
})
.nullable(),
comment: z
.object({
publicId: z.string(),
comment: z.string(),
createdBy: z.string().nullable(),
updatedAt: z.date().nullable(),
deletedAt: z.date().nullable(),
})
.nullable(),
attachment: z
.object({
publicId: z.string(),
filename: z.string(),
originalFilename: z.string().nullable(),
})
.nullable(),
});

View File

@@ -1,40 +0,0 @@
import { z } from "zod";
// Shared label schema used across board and card responses
export const labelSchema = z.object({
publicId: z.string(),
name: z.string(),
colourCode: z.string().nullable(),
});
// Shared checklist item schema
export const checklistItemResponseSchema = z.object({
publicId: z.string(),
title: z.string(),
completed: z.boolean(),
index: z.number(),
});
// Shared checklist schema with items
export const checklistResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
index: z.number(),
items: z.array(checklistItemResponseSchema),
});
// Shared user sub-object (used inside member/workspace responses)
export const userSchema = z.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string(),
image: z.string().nullable(),
});
// Workspace member sub-object
export const workspaceMemberSchema = z.object({
publicId: z.string(),
email: z.string(),
status: z.string(),
user: userSchema.nullable(),
});

View File

@@ -1,39 +0,0 @@
export {
boardListItemSchema,
boardDetailSchema,
boardBySlugSchema,
boardCreateResponseSchema,
boardUpdateResponseSchema,
} from "./board";
export {
cardCreateResponseSchema,
cardUpdateResponseSchema,
cardDetailSchema,
commentResponseSchema,
commentDeleteResponseSchema,
activityItemSchema,
} from "./card";
export {
labelSchema,
checklistItemResponseSchema,
checklistResponseSchema,
userSchema,
workspaceMemberSchema,
} from "./common";
export {
workspaceListItemSchema,
workspaceDetailSchema,
workspaceWithBoardsSchema,
workspaceCreateResponseSchema,
workspaceUpdateResponseSchema,
workspaceDeleteResponseSchema,
} from "./workspace";
export { listCreateResponseSchema, listUpdateResponseSchema } from "./list";
export { memberInviteResponseSchema } from "./member";
export { attachmentConfirmResponseSchema } from "./attachment";

View File

@@ -1,13 +0,0 @@
import { z } from "zod";
// ─── list.create ─────────────────────────────────────────────
export const listCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});
// ─── list.update / list.reorder ──────────────────────────────
export const listUpdateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
});

View File

@@ -1,6 +0,0 @@
import { z } from "zod";
// ─── member.invite ───────────────────────────────────────────
export const memberInviteResponseSchema = z.object({
publicId: z.string(),
});

View File

@@ -1,93 +0,0 @@
import { z } from "zod";
import { userSchema } from "./common";
// ─── workspace.all ───────────────────────────────────────────
export const workspaceListItemSchema = z.object({
role: z.string(),
workspace: z.object({
publicId: z.string(),
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
weekStartDay: z.number().nullable(),
deletedAt: z.date().nullable(),
}),
});
// ─── workspace.byId ──────────────────────────────────────────
const workspaceMemberDetailSchema = z.object({
publicId: z.string(),
email: z.string().optional(),
role: z.string(),
status: z.string(),
createdAt: z.date(),
user: z
.object({
id: z.string().nullable(),
name: z.string().nullable(),
email: z.string().optional(),
image: z.string().nullable(),
})
.nullable(),
});
const workspaceSubscriptionSchema = z.object({
plan: z.string(),
status: z.string(),
seats: z.number().nullable(),
unlimitedSeats: z.boolean().nullable(),
periodStart: z.date().nullable(),
periodEnd: z.date().nullable(),
});
export const workspaceDetailSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
showEmailsToMembers: z.boolean().nullable(),
weekStartDay: z.number().nullable(),
members: z.array(workspaceMemberDetailSchema),
subscriptions: z.array(workspaceSubscriptionSchema),
});
// ─── workspace.bySlug ────────────────────────────────────────
export const workspaceWithBoardsSchema = z.object({
publicId: z.string(),
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
boards: z.array(
z.object({
publicId: z.string(),
slug: z.string(),
name: z.string(),
}),
),
});
// ─── workspace.create ────────────────────────────────────────
export const workspaceCreateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
});
// ─── workspace.update ────────────────────────────────────────
export const workspaceUpdateResponseSchema = z.object({
publicId: z.string(),
name: z.string(),
slug: z.string(),
description: z.string().nullable(),
plan: z.enum(["free", "team", "pro", "enterprise"]),
showEmailsToMembers: z.boolean().nullable(),
weekStartDay: z.number().nullable(),
});
// ─── workspace.delete ────────────────────────────────────────
export const workspaceDeleteResponseSchema = z.object({
success: z.boolean(),
});

View File

@@ -63,11 +63,6 @@ const createAuthWithHeaders = (
headers,
query: { referenceId: input.workspacePublicId },
}),
setPassword: (input: { newPassword: string }) =>
auth.api.setPassword({
headers,
body: { newPassword: input.newPassword },
}),
},
};
};

View File

@@ -1,20 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLogger } = vi.hoisted(() => ({
mockLogger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@kan/db/repository/webhook.repo", () => ({
getActiveByWorkspaceId: vi.fn(),
}));
vi.mock("@kan/logger", () => ({
createLogger: vi.fn(() => mockLogger),
}));
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import {
sendWebhookToUrl,
@@ -43,7 +32,6 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -56,14 +44,12 @@ describe("webhook utilities", () => {
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
expect(payload.data.card).toEqual({
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
description: undefined,
dueDate: null,
listId: "list-456",
boardId: "board-789",
});
expect(payload.data.card.publicId).toBe("card-pub-123");
});
it("includes optional card fields when provided", () => {
@@ -72,7 +58,6 @@ describe("webhook utilities", () => {
"card.updated",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
description: "A description",
dueDate,
@@ -92,7 +77,6 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -113,7 +97,6 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -134,7 +117,6 @@ describe("webhook utilities", () => {
"card.created",
{
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
},
@@ -158,7 +140,6 @@ describe("webhook utilities", () => {
"card.updated",
{
id: "card-123",
publicId: "card-pub-123",
title: "Updated Title",
listId: "list-456",
},
@@ -174,34 +155,6 @@ describe("webhook utilities", () => {
title: { from: "Old Title", to: "Updated Title" },
});
});
it("preserves public list IDs in moved payloads", () => {
const payload = createCardWebhookPayload(
"card.moved",
{
id: "card-123",
publicId: "card-pub-123",
title: "Moved Card",
listId: "list-public-done",
},
{
boardId: "board-789",
listName: "Done",
changes: {
listId: { from: "list-public-backlog", to: "list-public-done" },
},
},
);
expect(payload.data.card.listId).toBe("list-public-done");
expect(payload.data.list).toEqual({
id: "list-public-done",
name: "Done",
});
expect(payload.data.changes).toEqual({
listId: { from: "list-public-backlog", to: "list-public-done" },
});
});
});
describe("sendWebhookToUrl", () => {
@@ -221,7 +174,6 @@ describe("webhook utilities", () => {
data: {
card: {
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
@@ -418,7 +370,6 @@ describe("webhook utilities", () => {
data: {
card: {
id: "card-123",
publicId: "card-pub-123",
title: "Test Card",
listId: "list-456",
boardId: "board-789",
@@ -483,6 +434,8 @@ describe("webhook utilities", () => {
});
it("continues sending to other webhooks when one fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
@@ -509,15 +462,11 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/webhook1",
event: "card.created",
error: "500 Error",
statusCode: 500,
}),
"Webhook delivery failed",
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Webhook delivery failed"),
);
consoleSpy.mockRestore();
});
it("handles empty webhook list", async () => {
@@ -529,6 +478,9 @@ describe("webhook utilities", () => {
});
it("catches and logs DB errors without throwing", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
new Error("DB connection failed"),
);
@@ -538,13 +490,12 @@ describe("webhook utilities", () => {
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
).resolves.toBeUndefined();
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
workspaceId: 1,
}),
"Failed to send webhooks for workspace",
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send webhooks for workspace:",
expect.any(Error),
);
consoleSpy.mockRestore();
});
});

View File

@@ -16,7 +16,6 @@ export interface WebhookPayload {
data: {
card: {
id: string;
publicId: string;
title: string;
description?: string | null;
dueDate?: string | null; // ISO string after JSON serialization
@@ -222,7 +221,6 @@ export function createCardWebhookPayload(
event: WebhookEventType,
card: {
id: string;
publicId: string;
title: string;
description?: string | null;
dueDate?: Date | null;
@@ -245,7 +243,6 @@ export function createCardWebhookPayload(
data: {
card: {
id: card.id,
publicId: card.publicId,
title: card.title,
description: card.description,
dueDate: card.dueDate?.toISOString() ?? null,

View File

@@ -170,7 +170,7 @@ export function createPlugins(db: dbClient) {
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? null;
return ctx.headers?.get("x-api-key") ?? undefined;
},
rateLimit: {
enabled: true,

View File

@@ -92,6 +92,14 @@ export const socialProvidersPlugin = () => ({
const providers = ctx.context.socialProviders.map((p) =>
p.id.toLowerCase(),
);
// Add OIDC provider if configured
if (
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_DISCOVERY_URL
) {
providers.push("oidc");
}
return ctx.json(providers);
},
),

View File

@@ -1,47 +0,0 @@
ALTER TABLE "card" ADD COLUMN "cardNumber" integer;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardPrefix" varchar(10) DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "cardCounter" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "card_list_number_idx" ON "card" USING btree ("listId","cardNumber");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_card_prefix_idx" ON "workspace" USING btree ("cardPrefix");--> statement-breakpoint
-- Populate cardPrefix for existing workspaces from their name
UPDATE "workspace"
SET "cardPrefix" = (
SELECT CASE
WHEN array_length(words, 1) = 1 THEN UPPER(LEFT(words[1], 3))
ELSE UPPER(LEFT(array_to_string(ARRAY(
SELECT LEFT(w, 1) FROM unnest(words) AS w WHERE w != ''
), ''), 4))
END
FROM (
SELECT regexp_split_to_array(trim("name"), '\s+') AS words
) sub
)
WHERE "cardPrefix" = '';--> statement-breakpoint
-- Assign sequential cardNumber to existing cards (including soft-deleted),
-- ordered by createdAt, scoped to workspace. Deleted cards still receive a
-- number so future un-archive/restore flows can keep their ticket ID.
WITH numbered AS (
SELECT
c.id,
ROW_NUMBER() OVER (PARTITION BY b."workspaceId" ORDER BY c."createdAt", c.id) AS rn
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE c."cardNumber" IS NULL
)
UPDATE "card" c
SET "cardNumber" = n.rn
FROM numbered n
WHERE c.id = n.id;--> statement-breakpoint
-- Update cardCounter on each workspace to the max cardNumber assigned
UPDATE "workspace" w
SET "cardCounter" = COALESCE((
SELECT MAX(c."cardNumber")
FROM "card" c
JOIN "list" l ON c."listId" = l.id
JOIN "board" b ON l."boardId" = b.id
WHERE b."workspaceId" = w.id AND c."cardNumber" IS NOT NULL
), 0);

View File

@@ -1,3 +0,0 @@
ALTER TABLE "subscription" ADD COLUMN "partnerLicenseKey" varchar(255);--> statement-breakpoint
ALTER TABLE "subscription" ADD COLUMN "partnerTier" integer;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "subscription_partner_license_key_idx" ON "subscription" USING btree ("partnerLicenseKey");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -225,26 +225,6 @@
"when": 1775170588247,
"tag": "20260402225628_AddTeamWorkspacePlan",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776809379931,
"tag": "20260421220939_AddCardNumber",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1778600989523,
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1778617946519,
"tag": "20260512203226_AddPartnerLicenseToSubscription",
"breakpoints": true
}
]
}

View File

@@ -209,7 +209,6 @@ export const getByPublicId = async (
workspace: {
columns: {
publicId: true,
cardPrefix: true,
},
with: {
members: {
@@ -221,7 +220,6 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
@@ -256,7 +254,6 @@ export const getByPublicId = async (
listId: true,
index: true,
dueDate: true,
cardNumber: true,
},
with: {
labels: {
@@ -281,7 +278,6 @@ export const getByPublicId = async (
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
@@ -426,7 +422,6 @@ export const getBySlug = async (
publicId: true,
name: true,
slug: true,
cardPrefix: true,
},
},
labels: {
@@ -453,7 +448,6 @@ export const getBySlug = async (
listId: true,
index: true,
dueDate: true,
cardNumber: true,
},
with: {
labels: {

View File

@@ -22,7 +22,6 @@ import {
labels,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
@@ -42,7 +41,6 @@ export const create = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
position: "start" | "end";
dueDate?: Date | null;
},
@@ -84,17 +82,6 @@ export const create = async (
`);
}
const [counterResult] = await tx
.update(workspaces)
.set({ cardCounter: sql`${workspaces.cardCounter} + 1` })
.where(eq(workspaces.id, cardInput.workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${cardInput.workspaceId} not found`);
const cardNumber = counterResult.cardCounter;
const result = await tx
.insert(cards)
.values({
@@ -104,10 +91,9 @@ export const create = async (
createdBy: cardInput.createdBy,
listId: cardInput.listId,
index: index,
cardNumber,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId });
if (!result[0]) throw new Error("Unable to create card");
@@ -255,14 +241,6 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
listId: true,
dueDate: true,
},
with: {
list: {
columns: {
publicId: true,
name: true,
},
},
},
where: eq(cards.publicId, cardPublicId),
});
};
@@ -287,7 +265,6 @@ export const bulkCreate = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
index: number;
importId?: number;
}[],
@@ -303,34 +280,6 @@ export const bulkCreate = async (
byList.set(item.listId, arr);
}
// Atomically reserve a contiguous range of cardNumbers per workspace by
// bumping cardCounter once per workspace.
const countsByWorkspace = new Map<number, number>();
for (const item of cardInput) {
countsByWorkspace.set(
item.workspaceId,
(countsByWorkspace.get(item.workspaceId) ?? 0) + 1,
);
}
const cardNumberByWorkspaceQueue = new Map<number, number[]>();
for (const [workspaceId, count] of countsByWorkspace.entries()) {
const [counterResult] = await tx
.update(workspaces)
.set({ cardCounter: sql`${workspaces.cardCounter} + ${count}` })
.where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter;
const start = last - count + 1;
const queue: number[] = [];
for (let n = start; n <= last; n++) queue.push(n);
cardNumberByWorkspaceQueue.set(workspaceId, queue);
}
const allValuesToInsert: {
publicId: string;
title: string;
@@ -338,7 +287,6 @@ export const bulkCreate = async (
createdBy: string;
listId: number;
index: number;
cardNumber: number;
importId?: number;
}[] = [];
@@ -353,12 +301,6 @@ export const bulkCreate = async (
let nextIndex = last ? last.index + 1 : 0;
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const it of sorted) {
const queue = cardNumberByWorkspaceQueue.get(it.workspaceId);
const cardNumber = queue?.shift();
if (cardNumber === undefined)
throw new Error(
`Failed to allocate cardNumber for workspace ${it.workspaceId}`,
);
allValuesToInsert.push({
publicId: it.publicId,
title: it.title,
@@ -366,7 +308,6 @@ export const bulkCreate = async (
createdBy: it.createdBy,
listId: it.listId,
index: nextIndex++,
cardNumber,
importId: it.importId,
});
}
@@ -485,7 +426,6 @@ export const getWithListAndMembersByPublicId = async (
description: true,
dueDate: true,
createdBy: true,
cardNumber: true,
},
with: {
labels: {
@@ -562,7 +502,6 @@ export const getWithListAndMembersByPublicId = async (
workspace: {
columns: {
publicId: true,
cardPrefix: true,
},
with: {
members: {
@@ -1006,7 +945,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
columns: { name: true, publicId: true },
columns: { name: true },
with: {
board: {
columns: {
@@ -1027,7 +966,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
listPublicId: result.list.publicId,
listName: result.list.name,
boardPublicId: result.list.board.publicId,
boardName: result.list.board.name,

View File

@@ -209,12 +209,10 @@ export const getByPublicId = async (db: dbClient, listPublicId: string) => {
return db.query.lists.findFirst({
columns: {
id: true,
publicId: true,
name: true,
boardId: true,
index: true,
},
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
where: eq(lists.publicId, listPublicId),
});
};
@@ -435,7 +433,6 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result
? {
id: result.id,
publicId: listPublicId,
name: result.name,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,

View File

@@ -7,7 +7,6 @@ export const updateById = async (
db: dbClient,
subscriptionId: number,
updates: {
plan?: string;
unlimitedSeats?: boolean;
status?: string;
seats?: number | null;
@@ -90,47 +89,3 @@ export const create = async (
const [result] = await db.insert(subscription).values(data).returning();
return result;
};
export const getByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
) => {
const result = await db.query.subscription.findFirst({
where: eq(subscription.partnerLicenseKey, partnerLicenseKey),
});
return result;
};
export const upsertByPartnerLicenseKey = async (
db: dbClient,
partnerLicenseKey: string,
data: {
plan: string;
status: string;
partnerTier: number;
seats: number | null;
unlimitedSeats: boolean;
referenceId?: string;
},
) => {
const existing = await getByPartnerLicenseKey(db, partnerLicenseKey);
if (existing) {
const [result] = await db
.update(subscription)
.set({ ...data, updatedAt: new Date() })
.where(eq(subscription.partnerLicenseKey, partnerLicenseKey))
.returning();
return result;
}
const [result] = await db
.insert(subscription)
.values({
partnerLicenseKey,
...data,
referenceId: data.referenceId ?? null,
})
.returning();
return result;
};

View File

@@ -1,11 +1,8 @@
import { and, count, desc, eq, isNotNull } from "drizzle-orm";
import { count, desc, eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import type { dbClient } from "@kan/db/client";
import { account, apikey, users } from "@kan/db/schema";
const PROVIDER_CREDENTIAL = "credential";
const PROVIDER_MAGIC_LINK = "magic-link";
import { apikey, users } from "@kan/db/schema";
export const getCount = async (db: dbClient) => {
const result = await db.select({ count: count() }).from(users);
@@ -14,58 +11,27 @@ export const getCount = async (db: dbClient) => {
};
export const getById = async (db: dbClient, userId: string) => {
const [user, credentialAccount, magicLinkAccount] = await Promise.all([
db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
image: true,
stripeCustomerId: true,
},
with: {
apiKeys: {
columns: {
id: true,
prefix: true,
key: true,
},
orderBy: desc(apikey.createdAt),
limit: 1,
return await db.query.users.findFirst({
columns: {
id: true,
name: true,
email: true,
image: true,
stripeCustomerId: true,
},
with: {
apiKeys: {
columns: {
id: true,
prefix: true,
key: true,
},
orderBy: desc(apikey.createdAt),
limit: 1,
},
where: eq(users.id, userId),
}),
db
.select({ id: account.id })
.from(account)
.where(
and(
eq(account.userId, userId),
eq(account.providerId, PROVIDER_CREDENTIAL),
isNotNull(account.password),
),
)
.limit(1),
db
.select({ id: account.id })
.from(account)
.where(
and(
eq(account.userId, userId),
eq(account.providerId, PROVIDER_MAGIC_LINK),
),
)
.limit(1),
]);
if (!user) return undefined;
return {
...user,
hasPassword: credentialAccount.length > 0,
hasMagicLinkAccount: magicLinkAccount.length > 0,
};
},
where: eq(users.id, userId),
});
};
export const getByStripeCustomerId = async (

View File

@@ -1,18 +1,17 @@
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
asc,
or,
sql,
} from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import type { Permission, Role } from "@kan/shared";
import {
boards,
cards,
@@ -20,11 +19,8 @@ import {
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import {
generateUID,
generateWorkspacePrefix,
getDefaultPermissions,
} from "@kan/shared";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
import * as permissionRepo from "./permission.repo";
@@ -34,22 +30,22 @@ const SYSTEM_ROLES: {
description: string;
hierarchyLevel: number;
}[] = [
{
name: "admin",
description: "Full access to all workspace features",
hierarchyLevel: 100,
},
{
name: "member",
description: "Standard member with create and edit permissions",
hierarchyLevel: 50,
},
{
name: "guest",
description: "View-only access",
hierarchyLevel: 10,
},
];
{
name: "admin",
description: "Full access to all workspace features",
hierarchyLevel: 100,
},
{
name: "member",
description: "Standard member with create and edit permissions",
hierarchyLevel: 50,
},
{
name: "guest",
description: "View-only access",
hierarchyLevel: 10,
},
];
export const getCount = async (db: dbClient) => {
const result = await db
@@ -79,12 +75,8 @@ export const create = async (
name: workspaceInput.name,
slug: workspaceInput.slug,
createdBy: workspaceInput.createdBy,
...(workspaceInput.description && {
description: workspaceInput.description,
}),
...(workspaceInput.description && { description: workspaceInput.description }),
...(workspaceInput.plan && { plan: workspaceInput.plan }),
cardPrefix: generateWorkspacePrefix(workspaceInput.name),
cardCounter: 0,
})
.returning({
id: workspaces.id,
@@ -266,12 +258,8 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
slug: true,
name: true,
},
where: and(
isNull(boards.deletedAt),
eq(boards.visibility, "public"),
eq(boards.isArchived, false),
),
orderBy: [asc(boards.name)],
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
orderBy: [asc(boards.name)]
},
},
where: and(
@@ -295,7 +283,6 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
slug: true,
plan: true,
weekStartDay: true,
cardPrefix: true,
deletedAt: true,
},
// https://github.com/drizzle-team/drizzle-orm/issues/2903
@@ -378,14 +365,6 @@ export const isUserInWorkspace = async (
return result?.id !== undefined;
};
const parseTicketId = (
query: string,
): { prefix: string; number: number } | null => {
const match = /^([A-Za-z0-9]{1,10})-(\d+)$/.exec(query);
if (!match) return null;
return { prefix: match[1]!.toUpperCase(), number: parseInt(match[2]!, 10) };
};
export const searchBoardsAndCards = async (
db: dbClient,
workspaceId: number,
@@ -394,8 +373,6 @@ export const searchBoardsAndCards = async (
) => {
const searchQuery = `%${query}%`;
const ticketId = parseTicketId(query.trim());
// Search for boards
const boardResults = await db
.select({
@@ -423,29 +400,9 @@ export const searchBoardsAndCards = async (
sql`similarity(${boards.name}, ${query}) DESC`,
desc(boards.updatedAt),
)
.limit(ticketId ? 0 : Math.ceil(limit * 0.4));
// Search for cards by ticket ID or by title
const cardWhereConditions = ticketId
? and(
eq(boards.workspaceId, workspaceId),
eq(cards.cardNumber, ticketId.number),
ilike(workspaces.cardPrefix, ticketId.prefix),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
)
: and(
eq(boards.workspaceId, workspaceId),
or(
ilike(cards.title, searchQuery),
sql`similarity(${cards.title}, ${query}) > 0.2`,
),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
);
.limit(Math.ceil(limit * 0.4));
// Search for cards
const cardResults = await db
.select({
publicId: cards.publicId,
@@ -454,36 +411,35 @@ export const searchBoardsAndCards = async (
boardPublicId: boards.publicId,
boardName: boards.name,
listName: lists.name,
cardNumber: cards.cardNumber,
updatedAt: cards.updatedAt,
createdAt: cards.createdAt,
})
.from(cards)
.innerJoin(lists, eq(cards.listId, lists.id))
.innerJoin(boards, eq(lists.boardId, boards.id))
.innerJoin(workspaces, eq(boards.workspaceId, workspaces.id))
.where(cardWhereConditions)
.orderBy(
...(ticketId
? [desc(cards.createdAt)]
: [
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
]),
.where(
and(
eq(boards.workspaceId, workspaceId),
or(
ilike(cards.title, searchQuery),
sql`similarity(${cards.title}, ${query}) > 0.2`,
),
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
),
)
.limit(ticketId ? limit : Math.floor(limit * 0.6));
.orderBy(
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
)
.limit(Math.floor(limit * 0.6));
// Combine results
const allResults = [
...boardResults.map((board) => ({
...board,
type: "board" as const,
})),
...cardResults.map((card) => ({
...card,
type: "card" as const,
})),
...boardResults.map((board) => ({ ...board, type: "board" as const })),
...cardResults.map((card) => ({ ...card, type: "card" as const })),
];
// Ensure we don't exceed the total limit

View File

@@ -2,7 +2,6 @@ import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
index,
integer,
pgEnum,
pgTable,
@@ -55,36 +54,27 @@ export type ActivityType = (typeof activityTypes)[number];
export const activityTypeEnum = pgEnum("card_activity_type", activityTypes);
export const cards = pgTable(
"card",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: text("title").notNull(),
description: text("description"),
index: integer("index").notNull(),
cardNumber: integer("cardNumber"),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
listId: bigint("listId", { mode: "number" })
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(
() => imports.id,
),
dueDate: timestamp("dueDate"),
},
(table) => [
index("card_list_number_idx").on(table.listId, table.cardNumber),
],
).enableRLS();
export const cards = pgTable("card", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
title: text("title").notNull(),
description: text("description"),
index: integer("index").notNull(),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
listId: bigint("listId", { mode: "number" })
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
dueDate: timestamp("dueDate"),
}).enableRLS();
export const cardsRelations = relations(cards, ({ one, many }) => ({
createdBy: one(users, {

View File

@@ -5,42 +5,31 @@ import {
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const subscription = pgTable(
"subscription",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
partnerLicenseKey: varchar("partnerLicenseKey", { length: 255 }),
partnerTier: integer("partnerTier"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => [
uniqueIndex("subscription_partner_license_key_idx").on(
table.partnerLicenseKey,
),
],
).enableRLS();
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),
periodStart: timestamp("periodStart"),
periodEnd: timestamp("periodEnd"),
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
seats: integer("seats"),
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
trialStart: timestamp("trialStart"),
trialEnd: timestamp("trialEnd"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
workspace: one(workspaces, {

View File

@@ -3,7 +3,6 @@ import {
bigint,
bigserial,
boolean,
index,
integer,
pgEnum,
pgTable,
@@ -39,31 +38,25 @@ export const workspacePlans = ["free", "team", "pro", "enterprise"] as const;
export type WorkspacePlan = (typeof workspacePlans)[number];
export const workspacePlanEnum = pgEnum("workspace_plan", workspacePlans);
export const workspaces = pgTable(
"workspace",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull().unique(),
plan: workspacePlanEnum("plan").notNull().default("free"),
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
weekStartDay: integer("weekStartDay").notNull().default(1),
cardPrefix: varchar("cardPrefix", { length: 10 }).notNull().default(""),
cardCounter: integer("cardCounter").notNull().default(0),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
},
(table) => [index("workspace_card_prefix_idx").on(table.cardPrefix)],
).enableRLS();
export const workspaces = pgTable("workspace", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull().unique(),
plan: workspacePlanEnum("plan").notNull().default("free"),
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
weekStartDay: integer("weekStartDay").notNull().default(1),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
}).enableRLS();
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
user: one(users, {

View File

@@ -1,26 +0,0 @@
export const generateWorkspacePrefix = (name: string): string => {
const words = name
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
if (words.length === 0) return "WS";
const firstWord = words[0];
if (words.length === 1 && firstWord) {
const word = firstWord.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
return word.slice(0, 3) || "WS";
}
const initials = words
.map((w) => {
const cleaned = w.replace(/[^a-zA-Z0-9]/g, "");
return cleaned.length > 0 ? cleaned[0]! : "";
})
.filter((c) => c.length > 0)
.join("")
.toUpperCase()
.slice(0, 4);
return initials || "WS";
};

View File

@@ -1,6 +1,5 @@
export * from "./generateUID";
export * from "./generateSlug";
export * from "./generateWorkspacePrefix";
export * from "./subscriptions";
export * from "./email";
export * from "./dueDateFilters";