Compare commits

...

16 Commits

Author SHA1 Message Date
Henry
b60c48838d fix: allow reordering without supplying an index when updating cards 2026-05-11 12:54:35 +01:00
hjball
c0962e9f70 chore: compile translations 2026-04-28 21:31:18 +00:00
hjball
3e6aec2d6b chore: update translations 2026-04-28 21:31:14 +00:00
Florent Azonnoudo
4658b2961c feat: add ticket numbers to cards with workspace prefix (#443)
* feat: add ticket numbers to cards with workspace prefix

- Add cardNumber to cards
- Add cardPrefix/cardCounter to workspace
- Populate initial prefixes and numbers via new migration
- Introduce generateWorkspacePrefix util and export it
- Include cardNumber in card-related API responses and search results
- Render and display tickets as PREFIX-NUMBER in UI
- Update Card component to accept ticketNumber
- Update CardModal and board cards to show number when available
- Add cardNumber to CommandPalette search results type

* feat: regen migration

* feat: enhance card and workspace schemas with cardNumber and indexing

- Updated the card repository to allocate card numbers atomically per workspace.
- Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance.
- Enhanced the workspace schema to include an index on cardPrefix for optimized lookups.
- Adjusted the migration journal to reflect the new schema changes and their timestamps.
- Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs.

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2026-04-28 22:29:41 +01:00
hjball
bc8b5463b3 chore: compile translations 2026-04-20 21:37:14 +00:00
hjball
9eee59a877 chore: update translations 2026-04-20 21:37:10 +00:00
Henry
e32e400976 chore: clean up types (#476) 2026-04-20 22:35:39 +01:00
Louie Chillingworth
b4b07f3120 fix: remove duplicate OIDC provider registration in getSocialProviders (#475)
The getSocialProviders endpoint was manually pushing "oidc" to the 
providers array when OIDC env vars are present, but Better Auth already 
includes the OIDC provider in ctx.context.socialProviders when it is 
configured. This resulted in ["oidc","oidc"] being returned from 
/api/auth/social-providers, causing a duplicate provider registration 
that led to a TypeError: Cannot read properties of null (reading 'id') 
during the OIDC login callback, preventing all OIDC logins.

Fix: remove the manual providers.push("oidc") block as it is redundant.
2026-04-20 21:20:23 +01:00
vvaswani
e39faa1172 fix: use stable public ids for card webhook (#463)
Signed-off-by: Vikram Vaswani <2571660+vvaswani@users.noreply.github.com>
2026-04-20 21:19:31 +01:00
hjball
b46979f539 chore: update translations 2026-04-18 20:42:20 +00:00
Nick Meinhold
f4237fffe6 fix: redirect to boards list after archiving/unarchiving a board (#459)
Previously, archiving or unarchiving a board left the user on the
(now archived/unarchived) board view with no clear navigation path.
This redirects to /boards after either action, matching the existing
behaviour of delete and move operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 21:41:04 +01:00
Redox
1d9bb96367 Don't default NEXT_PUBLIC_STORAGE_URL to empty string (#455) 2026-04-18 21:33:29 +01:00
hjball
bcb449e394 chore: update translations 2026-04-18 20:30:54 +00:00
Morfixx
ae4bc77407 feat: added schemas for openapi (#452) 2026-04-18 21:29:35 +01:00
hjball
6056167063 chore: update translations 2026-04-18 08:33:16 +00:00
Henry
adf91c7dfd refactor: remove legacy code from NewWorkspaceForm (#471) 2026-04-18 09:31:51 +01:00
70 changed files with 7262 additions and 1560 deletions

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,8 +372,7 @@ checksums:
QD8opX/origin/2/0: faa8a2576972b4eb31565d8ea6098f43
QD8opX/translation: 101ff39aab674c033c15ab978d0d8bac
YhvWXb/message: 336a8c70a91adc49c15266167ee84cc0
YhvWXb/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
YhvWXb/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
YhvWXb/translation: 336a8c70a91adc49c15266167ee84cc0
i0ZMQl/message: f739058266a2cb6a3d7cca14de09c69a
i0ZMQl/origin/0/0: 0ad2af981579a62b084b3d41462c5546
@@ -593,9 +592,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
@@ -629,9 +628,8 @@ checksums:
dtQIWT/origin/0/0: c0b59968b2b234167c488a05cc38710b
dtQIWT/translation: 9d2308d578ec21bd22374e880fe3bd3c
AUYALh/message: ee2b0671e00972773210c5be5a9ccb89
AUYALh/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
AUYALh/origin/1/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
AUYALh/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
AUYALh/translation: ee2b0671e00972773210c5be5a9ccb89
VbyRUy/message: 502237cd1044491c48494aa60967a985
VbyRUy/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
@@ -701,10 +699,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
@@ -736,6 +734,9 @@ checksums:
uvH2xS/message: ecc2617544a6d385d18678577fa112fd
uvH2xS/origin/0/0: 97bd206a05414e2a571020d64e166093
uvH2xS/translation: ecc2617544a6d385d18678577fa112fd
7mrkyO/translation: d3853b58e9b736eefb0f9bf400daf4d7
7mrkyO/message: d3853b58e9b736eefb0f9bf400daf4d7
7mrkyO/origin/0/0: 1b05beba6bdd908015fa8e798717f024
9PaIxv/message: da95932e7a1465a5d21aa3b855a46dc2
9PaIxv/origin/0/0: c0b59968b2b234167c488a05cc38710b
9PaIxv/translation: da95932e7a1465a5d21aa3b855a46dc2
@@ -832,12 +833,11 @@ 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: ff4a160eca5f8da4f075fd1dd6061fa6
j9IZZ0/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
j9IZZ0/translation: 7ba841d0946eb04fa3d17d74365be37b
OwneeH/message: e97476d280d4ebfd196d6c5336f3511f
OwneeH/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -1049,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
@@ -1332,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
@@ -1380,6 +1380,9 @@ checksums:
2Py5S%2F/message: fe1183a7439299210d8ab7379b8f4d01
2Py5S%2F/origin/0/0: 83911e3eacbad4583e2b1647a784d154
2Py5S%2F/translation: fe1183a7439299210d8ab7379b8f4d01
VOwhhz/translation: 613f43526ea128b2749f70878f10008c
VOwhhz/message: 613f43526ea128b2749f70878f10008c
VOwhhz/origin/0/0: 1b05beba6bdd908015fa8e798717f024
iwr7AP/message: 27c24b56895defecd10b0171b39bc93f
iwr7AP/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
iwr7AP/translation: 27c24b56895defecd10b0171b39bc93f
@@ -1509,9 +1512,8 @@ checksums:
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/2/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
8Is1ih/origin/1/0: dbad0c8d7863cb41f5495264f82e7081
8Is1ih/translation: 624592ba2ecf647a3fe60d493c1ee71a
EEHmPC/message: 8c1be80f5b2fcc112922282afbe30287
EEHmPC/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
@@ -1595,9 +1597,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
@@ -1868,9 +1870,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
@@ -1964,8 +1966,9 @@ checksums:
fuwKpE/origin/1/0: fd0e66cef8d46e96bcf3143a12f9b6db
fuwKpE/origin/2/0: fc0c18a095a68277a0f90a115942683b
fuwKpE/origin/3/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/4/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/5/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/origin/4/0: 1b05beba6bdd908015fa8e798717f024
fuwKpE/origin/5/0: 9c8b544f5db130cd7c5eda9064ba3957
fuwKpE/origin/6/0: faa8a2576972b4eb31565d8ea6098f43
fuwKpE/translation: 346a18a54564881c888b95873d03df44
nYNMZZ/message: d5a8a1b38eaf02d02cd6ac2211b42a92
nYNMZZ/origin/0/0: 97bd206a05414e2a571020d64e166093
@@ -2191,9 +2194,9 @@ checksums:
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
@@ -2228,10 +2231,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
@@ -2267,9 +2270,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
@@ -2309,9 +2312,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
@@ -2347,6 +2350,14 @@ 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
@@ -2417,12 +2428,12 @@ checksums:
vlxQFL/message: 730f33b8f1fc002c4f393ccd262b6664
vlxQFL/origin/0/0: afee530613110e248ffde6b56872ebed
vlxQFL/translation: 730f33b8f1fc002c4f393ccd262b6664
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
HQVm6e/message: ec66b543a593e9dabf47de5979892d8d
HQVm6e/origin/0/0: 69d8634b177fdf937764b406fd477547
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
HQVm6e/translation: ec66b543a593e9dabf47de5979892d8d
gKmoow/message: 83997c77cb0dc8c8c90f7ab706aa817f
gKmoow/origin/0/0: 69d8634b177fdf937764b406fd477547
gKmoow/translation: 83997c77cb0dc8c8c90f7ab706aa817f
OAYToL/message: 1c989752736fa41ecdd68ea890f16a15
OAYToL/origin/0/0: 15792234f408027822ee71ccddde6f9b
OAYToL/translation: 1c989752736fa41ecdd68ea890f16a15
@@ -2441,6 +2452,9 @@ checksums:
kp6L3T/message: b7eadb89c615874f416d9658d0428c4c
kp6L3T/origin/0/0: 30791e26a172d47d3cb0693e82679246
kp6L3T/translation: b7eadb89c615874f416d9658d0428c4c
pEb95p/translation: 1d4c2a0ec7e4c707a4470fa4262b495d
pEb95p/message: 1d4c2a0ec7e4c707a4470fa4262b495d
pEb95p/origin/0/0: 1b05beba6bdd908015fa8e798717f024
MHrjPM/message: 344e64395eaff6822a57d18623853e1a
MHrjPM/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
MHrjPM/translation: 344e64395eaff6822a57d18623853e1a
@@ -2488,6 +2502,9 @@ checksums:
LeM5RY/message: dfeac280858332082ad34d2623a59863
LeM5RY/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
LeM5RY/translation: dfeac280858332082ad34d2623a59863
5MPY04/translation: 661f2e21a85fbe1281b141a8041c8778
5MPY04/message: 661f2e21a85fbe1281b141a8041c8778
5MPY04/origin/0/0: 1b05beba6bdd908015fa8e798717f024
W1ewR0/message: ddcac5d14d48358b043a9487b42eec16
W1ewR0/origin/0/0: fc0c18a095a68277a0f90a115942683b
W1ewR0/origin/1/0: 1b05beba6bdd908015fa8e798717f024
@@ -2507,6 +2524,9 @@ 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
@@ -2526,6 +2546,9 @@ 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
@@ -2544,9 +2567,13 @@ 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
@@ -2589,6 +2616,9 @@ 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
@@ -2633,15 +2663,14 @@ checksums:
7Ufoyg/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
7Ufoyg/translation: 5c418701b27b9acbc5dd199719007362
mx8%2B1u/message: f2949ca2dc18b0063af92e60779abb65
mx8%2B1u/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
mx8%2B1u/origin/0/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/1/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/2/0: c0b59968b2b234167c488a05cc38710b
mx8%2B1u/origin/3/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/4/0: dbad0c8d7863cb41f5495264f82e7081
mx8%2B1u/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
mx8%2B1u/origin/3/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
@@ -2681,8 +2710,7 @@ checksums:
IelE1h/origin/2/0: 5f6c4a0c9be5098099167871ae1564af
IelE1h/translation: 972773025e763ddf53273df6d37a729a
cWtfn1/message: 9a91ea70f5eb55aac96640bf0fd7feff
cWtfn1/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
cWtfn1/origin/1/0: 69d8634b177fdf937764b406fd477547
cWtfn1/origin/0/0: 69d8634b177fdf937764b406fd477547
cWtfn1/translation: 9a91ea70f5eb55aac96640bf0fd7feff
b3Thhd/message: 5b7b755f05330164dbfb00096c500ee3
b3Thhd/origin/0/0: 12c1fab543c8848bcb10107d47336736
@@ -2755,9 +2783,15 @@ 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
@@ -2771,6 +2805,9 @@ 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
@@ -2877,9 +2914,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
@@ -2923,9 +2960,9 @@ checksums:
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
@@ -2962,12 +2999,12 @@ checksums:
25fAUC/message: 3e4cce96bc1197a1caa2b7fb4d3c7e53
25fAUC/origin/0/0: d1e1d7ccce60e05570bedfbb93fc9416
25fAUC/translation: 3e4cce96bc1197a1caa2b7fb4d3c7e53
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/message: 751a534fd6e62a92ff64c46894c7db86
GRAGsB/origin/0/0: 69d8634b177fdf937764b406fd477547
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
GRAGsB/translation: 751a534fd6e62a92ff64c46894c7db86
j0o6ml/message: 1e036ffca9d9cb9e2941b83fbbf375c0
j0o6ml/origin/0/0: 69d8634b177fdf937764b406fd477547
j0o6ml/translation: 1e036ffca9d9cb9e2941b83fbbf375c0
GnSSGm/message: 98de6f65c567c6654cc0adce3c9d5656
GnSSGm/origin/0/0: 33bf266228f835e12c0bfb0e5efb3be9
GnSSGm/translation: 98de6f65c567c6654cc0adce3c9d5656
@@ -2980,10 +3017,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,6 +33,7 @@ type SearchResult =
boardPublicId: string;
boardName: string;
listName: string;
cardNumber: number | null;
updatedAt: Date | null;
createdAt: Date;
type: "card";
@@ -159,8 +160,17 @@ export default function CommandPallette({
)}
</div>
<div className="min-w-0 flex-1 text-left">
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
{result.title}
<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>
{result.type === "card" && (
<div className="truncate text-xs text-light-700 dark:text-dark-700">

View File

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

View File

@@ -1,21 +1,14 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiBolt,
HiCheck,
HiCheckBadge,
HiInformationCircle,
HiXMark,
} from "react-icons/hi2";
import { HiCheck, HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useDebounce } from "~/hooks/useDebounce";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
watch,
trigger,
clearErrors,
setValue,
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
const slug = watch("slug");
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
// Pro toggle state management
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
// Validate slug only after debounce
useEffect(() => {
if (isTyping) {
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (values, variables) => {
onSuccess: (values) => {
if (values.publicId && values.name) {
void utils.workspace.all.invalidate();
switchWorkspace({
@@ -132,57 +118,8 @@ export function NewWorkspaceForm() {
if (nameElement) nameElement.focus();
}, []);
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
useEffect(() => {
if (
isCloudEnv &&
!checkWorkspaceSlugAvailability.isPending &&
isValidSlug
) {
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
setShouldShowBenefits(isAvailable);
// Automatically enable Pro toggle when slug is available
if (isAvailable) {
setIsProToggleEnabled(true);
setLastAvailableSlug(slug);
}
}
}, [
isValidSlug,
isWorkspaceSlugAvailable?.isAvailable,
checkWorkspaceSlugAvailability.isPending,
slug,
isCloudEnv,
]);
// Reset benefits when slug becomes invalid
useEffect(() => {
if (!isValidSlug) {
setShouldShowBenefits(false);
}
}, [isValidSlug]);
// Handle Pro toggle changes
const handleProToggleChange = () => {
if (isProToggleEnabled) {
// Turning off: clear the slug and disable Pro
setValue("slug", "");
setIsProToggleEnabled(false);
} else {
// Turning on: restore the last available slug if we have one
if (lastAvailableSlug) {
setValue("slug", lastAvailableSlug);
}
setIsProToggleEnabled(true);
}
};
const showProBenefits = isProToggleEnabled;
const onSubmit = (values: FormValues) => {
// Don't submit if slug is provided but not available
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
@@ -191,7 +128,7 @@ export function NewWorkspaceForm() {
createWorkspace.mutate({
name: values.name,
slug: !isCloudEnv && values.slug ? values.slug : undefined,
slug: values.slug,
});
};
@@ -254,11 +191,7 @@ export function NewWorkspaceForm() {
? t`This workspace URL is reserved`
: undefined)
}
prefix={
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
? "kan.bn/"
: `${env("NEXT_PUBLIC_BASE_URL")}/`
}
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
iconRight={
slug && slug.length >= 3 && !errors.slug ? (
isWorkspaceSlugAvailable?.isAvailable ? (
@@ -275,68 +208,9 @@ export function NewWorkspaceForm() {
}
}}
/>
{slug && slug.length >= 3 && shouldShowBenefits && (
<div className="mt-2 flex items-center gap-1">
<HiInformationCircle className="h-4 w-4 text-dark-900" />
<p className="text-xs text-gray-500 dark:text-dark-900">
{t`Custom URLs require upgrading to a Pro plan`}
</p>
</div>
)}
</div>
{showProBenefits && (
<div className="mt-6">
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Unlimited members`}
</span>
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
{t`Launch offer`}
</span>
</div>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Custom workspace URL`}
</span>
</div>
<div className="flex items-center space-x-3">
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
<div className="flex items-center space-x-2">
<span className="text-xs text-neutral-900 dark:text-dark-1000">
{t`Board analytics`}
</span>
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
{t`Coming soon`}
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
<div
className={twMerge(
"mt-6 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
!showProBenefits && "mt-12",
)}
>
{/* Pro Toggle - only show in cloud environment */}
{isCloudEnv && (
<Toggle
isChecked={isProToggleEnabled}
onChange={handleProToggleChange}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"

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, ru, ptBR} from "date-fns/locale";
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -19,7 +19,7 @@ export function useLocalisation() {
nl,
ru,
pl,
"pt-BR": ptBR,
ptbr: 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

@@ -20,6 +20,7 @@ interface Workspace {
plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
cardPrefix: string;
}
const initialWorkspace: Workspace = {
@@ -30,6 +31,7 @@ const initialWorkspace: Workspace = {
plan: "free" as const,
role: "member",
weekStartDay: 1,
cardPrefix: "",
};
const initialAvailableWorkspaces: Workspace[] = [];
@@ -90,6 +92,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
cardPrefix: workspace.cardPrefix,
hasLoaded: true,
})) as Workspace[];
@@ -121,6 +124,7 @@ 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) {
@@ -141,6 +145,7 @@ 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 "pt-BR":
return (await import("~/locales/pt-BR/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,3 +1,4 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
@@ -29,6 +30,7 @@ export default function BoardDropdown({
isFavorite?: boolean;
boardName?: string;
}) {
const router = useRouter();
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
@@ -47,6 +49,7 @@ 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,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -25,6 +26,7 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -67,6 +69,11 @@ 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,6 +146,10 @@ 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),
),
@@ -158,6 +162,9 @@ 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,6 +757,11 @@ 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,7 +525,6 @@ 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,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -18,11 +19,13 @@ 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();
@@ -53,12 +56,40 @@ 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,6 +340,14 @@ 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
@@ -347,6 +355,11 @@ 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

@@ -41,6 +41,7 @@ export default function WorkspaceNameView() {
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const { showPopup } = usePopup();
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const effectivePlan = isProToggle ? "pro" : plan;
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
@@ -109,7 +110,7 @@ export default function WorkspaceNameView() {
const handleContinue = async () => {
if (!name.trim()) return;
if (plan === "solo") {
if (effectivePlan === "solo") {
createWorkspace.mutate({
name: name.trim(),
...(description.trim() && { description: description.trim() }),
@@ -128,11 +129,11 @@ export default function WorkspaceNameView() {
...(description.trim() && {
workspaceDescription: description.trim(),
}),
...(plan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: window.location.pathname + window.location.search,
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
successUrl: "/boards",
billing,
plan,
plan: effectivePlan,
}),
});
const data = await response.json();
@@ -268,7 +269,7 @@ export default function WorkspaceNameView() {
variant="ghost"
onClick={() =>
router.replace(
`/onboarding/select-plan?plan=${plan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
)
}
>

View File

@@ -140,6 +140,11 @@ 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

@@ -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

@@ -13,20 +13,29 @@ 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({ message: t`Webhook deleted successfully`, type: "success" });
showPopup({
header: t`Webhook deleted`,
message: t`Webhook deleted successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to delete webhook`,
message: error.message || t`Failed to delete webhook`,
type: "error",
icon: "error",
});
},
});
@@ -35,7 +44,7 @@ export function DeleteWebhookConfirmation({
if (!webhookPublicId) return;
deleteWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};

View File

@@ -44,7 +44,12 @@ 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);
@@ -101,13 +106,18 @@ export function NewWebhookModal({
const createWebhookMutation = api.webhook.create.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook created successfully`, type: "success" });
showPopup({
header: t`Webhook created`,
message: t`Webhook created successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to create webhook`,
message: error.message || t`Failed to create webhook`,
type: "error",
icon: "error",
});
},
});
@@ -115,13 +125,18 @@ export function NewWebhookModal({
const updateWebhookMutation = api.webhook.update.useMutation({
onSuccess: () => {
void utils.webhook.list.invalidate({ workspacePublicId });
showPopup({ message: t`Webhook updated successfully`, type: "success" });
showPopup({
header: t`Webhook updated`,
message: t`Webhook updated successfully`,
icon: "success",
});
closeModal();
},
onError: (error) => {
showPopup({
header: t`Unable to update webhook`,
message: error.message || t`Failed to update webhook`,
type: "error",
icon: "error",
});
},
});
@@ -129,19 +144,25 @@ export function NewWebhookModal({
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({
header: t`Test sent`,
message: t`Test webhook sent successfully!`,
icon: "success",
});
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
setIsTestingWebhook(false);
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "error",
});
setIsTestingWebhook(false);
},
@@ -151,7 +172,7 @@ export function NewWebhookModal({
if (isEdit && webhookPublicId) {
updateWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
name: data.name,
url: data.url,
secret: data.secret || undefined,
@@ -174,11 +195,12 @@ export function NewWebhookModal({
setIsTestingWebhook(true);
testWebhookMutation.mutate({
workspacePublicId,
webhookPublicId: webhookPublicId as string,
webhookPublicId: webhookPublicId,
});
};
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
const isPending =
createWebhookMutation.isPending || updateWebhookMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)}>
@@ -231,7 +253,11 @@ 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}
/>
@@ -252,7 +278,7 @@ export function NewWebhookModal({
{webhookEvents.map((event) => (
<label
key={event}
className="flex items-center space-x-2 cursor-pointer"
className="flex cursor-pointer items-center space-x-2"
>
<input
type="checkbox"
@@ -262,11 +288,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="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
/>
<span className="text-sm text-light-900 dark:text-dark-900">
{event}
@@ -277,17 +303,19 @@ 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 items-center space-x-2 cursor-pointer">
<label className="flex cursor-pointer items-center space-x-2">
<input
type="checkbox"
{...register("active")}
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 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,
weekStartDay: newValue as 0 | 1 | 6,
});
};

View File

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

View File

@@ -171,18 +171,20 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
const testWebhookMutation = api.webhook.test.useMutation({
onSuccess: (result) => {
if (result.success) {
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
} else {
showPopup({
header: t`Test failed`,
message: result.error || t`Webhook test failed`,
type: "error",
icon: "error",
});
}
},
onError: (error) => {
showPopup({
header: t`Unable to test webhook`,
message: error.message || t`Failed to test webhook`,
type: "error",
icon: "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,6 +8,7 @@ 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";
@@ -110,7 +111,7 @@ export const attachmentRouter = createTRPCRouter({
size: z.number().positive(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
.output(attachmentConfirmResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

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

View File

@@ -10,6 +10,14 @@ 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";
@@ -42,7 +50,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
.output(cardCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -70,6 +78,7 @@ export const cardRouter = createTRPCRouter({
description: input.description,
createdBy: userId,
listId: list.id,
workspaceId: list.workspaceId,
position: input.position,
dueDate: input.dueDate ?? null,
});
@@ -181,7 +190,7 @@ export const cardRouter = createTRPCRouter({
title: input.title,
description: input.description,
dueDate: input.dueDate ?? null,
listId: String(newCard.listId),
listId: list.publicId,
},
{
boardId: list.boardPublicId,
@@ -215,7 +224,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -288,7 +297,7 @@ export const cardRouter = createTRPCRouter({
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
.output(commentResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -376,7 +385,7 @@ export const cardRouter = createTRPCRouter({
commentPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.output(commentDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -435,7 +444,7 @@ export const cardRouter = createTRPCRouter({
createdBy: userId,
});
return deletedComment;
return { publicId: input.commentPublicId };
}),
addOrRemoveLabel: protectedProcedure
.meta({
@@ -639,25 +648,7 @@ export const cardRouter = createTRPCRouter({
},
})
.input(z.object({ cardPublicId: z.string().min(12) }))
.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;
}[];
}
>(),
)
.output(cardDetailSchema)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
@@ -763,15 +754,7 @@ export const cardRouter = createTRPCRouter({
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
activities: z.array(activityItemSchema),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
@@ -871,7 +854,7 @@ export const cardRouter = createTRPCRouter({
dueDate: z.date().nullable().optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
.output(cardUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -906,9 +889,18 @@ export const cardRouter = createTRPCRouter({
);
let newListId: number | undefined;
let newList:
| {
id: number;
publicId: string;
name: string;
boardId: number;
index: number;
}
| undefined;
if (input.listPublicId) {
const newList = await listRepo.getByPublicId(
newList = await listRepo.getByPublicId(
ctx.db,
input.listPublicId,
);
@@ -953,7 +945,7 @@ export const cardRouter = createTRPCRouter({
);
}
if (input.index !== undefined) {
if (input.index !== undefined || newListId !== undefined) {
result = await cardRepo.reorder(ctx.db, {
cardId: existingCard.id,
newIndex: input.index,
@@ -1055,8 +1047,19 @@ export const cardRouter = createTRPCRouter({
) {
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
}
if (newListId && existingCard.listId !== newListId) {
webhookChanges.listId = { from: existingCard.listId, to: newListId };
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!,
};
}
// Fire webhooks (non-blocking)
@@ -1064,20 +1067,18 @@ export const cardRouter = createTRPCRouter({
ctx.db,
card.workspaceId,
createCardWebhookPayload(
newListId && existingCard.listId !== newListId
? "card.moved"
: "card.updated",
movedToNewList ? "card.moved" : "card.updated",
{
id: String(result.id),
title: result.title,
description: result.description,
dueDate: result.dueDate,
listId: String(newListId ?? existingCard.listId),
listId: currentWebhookListPublicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: currentWebhookListName,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1167,12 +1168,12 @@ export const cardRouter = createTRPCRouter({
title: fullCard.title,
description: fullCard.description,
dueDate: fullCard.dueDate,
listId: String(fullCard.listId),
listId: fullCard.list.publicId,
},
{
boardId: card.boardPublicId,
boardName: card.boardName,
listName: card.listName,
listName: fullCard.list.name,
user: ctx.user
? { id: ctx.user.id, name: ctx.user.name }
: undefined,
@@ -1272,6 +1273,7 @@ export const cardRouter = createTRPCRouter({
description: sourceCard.description ?? "",
createdBy: userId,
listId: targetList.id,
workspaceId: targetList.workspaceId,
position: "end",
dueDate: sourceCard.dueDate ?? null,
});

View File

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

View File

@@ -7,6 +7,7 @@ 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({
@@ -27,7 +28,7 @@ export const listRouter = createTRPCRouter({
boardPublicId: z.string().min(12),
}),
)
.output(z.custom<Awaited<ReturnType<typeof listRepo.create>>>())
.output(listCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -163,12 +164,7 @@ export const listRouter = createTRPCRouter({
index: z.number().optional(),
}),
)
.output(
z.custom<
| Awaited<ReturnType<typeof listRepo.update>>
| Awaited<ReturnType<typeof listRepo.reorder>>
>(),
)
.output(listUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

View File

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

View File

@@ -7,6 +7,14 @@ 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({
@@ -22,9 +30,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.void())
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getAllByUserId>>>(),
)
.output(z.array(workspaceListItemSchema))
.query(async ({ ctx }) => {
const userId = ctx.user?.id;
@@ -50,11 +56,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(
z.custom<
Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>
>(),
)
.output(workspaceDetailSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -134,7 +136,7 @@ export const workspaceRouter = createTRPCRouter({
return {
...result,
members: sanitizedMembers,
} as Awaited<ReturnType<typeof workspaceRepo.getByPublicIdWithMembers>>;
};
}
return {
@@ -162,9 +164,7 @@ export const workspaceRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
)
.output(workspaceWithBoardsSchema)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -211,7 +211,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
.output(workspaceCreateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
const userEmail = ctx.user?.email;
@@ -270,7 +270,13 @@ export const workspaceRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR",
});
return result;
return {
publicId: result.publicId,
name: result.name!,
slug: result.slug!,
description: result.description ?? null,
plan: result.plan!,
};
}),
update: protectedProcedure
.meta({
@@ -300,7 +306,7 @@ export const workspaceRouter = createTRPCRouter({
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
.output(workspaceUpdateResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -383,7 +389,7 @@ export const workspaceRouter = createTRPCRouter({
},
})
.input(z.object({ workspacePublicId: z.string().min(12) }))
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.hardDelete>>>())
.output(workspaceDeleteResponseSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
@@ -405,12 +411,12 @@ export const workspaceRouter = createTRPCRouter({
});
await assertPermission(ctx.db, userId, workspace.id, "workspace:delete");
const result = await workspaceRepo.hardDelete(
await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
return result;
return { success: true };
}),
checkSlugAvailability: publicProcedure
.meta({
@@ -515,6 +521,7 @@ 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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,130 @@
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

@@ -0,0 +1,216 @@
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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,13 @@
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

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

View File

@@ -0,0 +1,93 @@
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

@@ -1,9 +1,20 @@
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,
@@ -155,6 +166,33 @@ 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",
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", () => {
@@ -434,8 +472,6 @@ describe("webhook utilities", () => {
});
it("continues sending to other webhooks when one fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
{
id: 1,
@@ -462,11 +498,15 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Webhook delivery failed"),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://example.com/webhook1",
event: "card.created",
error: "500 Error",
statusCode: 500,
}),
"Webhook delivery failed",
);
consoleSpy.mockRestore();
});
it("handles empty webhook list", async () => {
@@ -478,9 +518,6 @@ 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"),
);
@@ -490,12 +527,13 @@ describe("webhook utilities", () => {
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to send webhooks for workspace:",
expect.any(Error),
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
workspaceId: 1,
}),
"Failed to send webhooks for workspace",
);
consoleSpy.mockRestore();
});
});

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") ?? undefined;
return ctx.headers?.get("x-api-key") ?? null;
},
rateLimit: {
enabled: true,

View File

@@ -92,14 +92,6 @@ 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

@@ -0,0 +1,47 @@
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);

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,13 @@
"when": 1775170588247,
"tag": "20260402225628_AddTeamWorkspacePlan",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1776809379931,
"tag": "20260421220939_AddCardNumber",
"breakpoints": true
}
]
}

View File

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

View File

@@ -22,6 +22,7 @@ import {
labels,
lists,
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
@@ -41,6 +42,7 @@ export const create = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
position: "start" | "end";
dueDate?: Date | null;
},
@@ -82,6 +84,17 @@ 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({
@@ -91,9 +104,10 @@ 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 });
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber });
if (!result[0]) throw new Error("Unable to create card");
@@ -241,6 +255,14 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
listId: true,
dueDate: true,
},
with: {
list: {
columns: {
publicId: true,
name: true,
},
},
},
where: eq(cards.publicId, cardPublicId),
});
};
@@ -265,6 +287,7 @@ export const bulkCreate = async (
description: string;
createdBy: string;
listId: number;
workspaceId: number;
index: number;
importId?: number;
}[],
@@ -280,6 +303,34 @@ 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;
@@ -287,6 +338,7 @@ export const bulkCreate = async (
createdBy: string;
listId: number;
index: number;
cardNumber: number;
importId?: number;
}[] = [];
@@ -301,6 +353,12 @@ 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,
@@ -308,6 +366,7 @@ export const bulkCreate = async (
createdBy: it.createdBy,
listId: it.listId,
index: nextIndex++,
cardNumber,
importId: it.importId,
});
}
@@ -426,6 +485,7 @@ export const getWithListAndMembersByPublicId = async (
description: true,
dueDate: true,
createdBy: true,
cardNumber: true,
},
with: {
labels: {
@@ -502,6 +562,7 @@ export const getWithListAndMembersByPublicId = async (
workspace: {
columns: {
publicId: true,
cardPrefix: true,
},
with: {
members: {
@@ -945,7 +1006,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
columns: { name: true },
columns: { name: true, publicId: true },
with: {
board: {
columns: {
@@ -966,6 +1027,7 @@ 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,10 +209,12 @@ 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: eq(lists.publicId, listPublicId),
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
});
};
@@ -433,6 +435,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result
? {
id: result.id,
publicId: listPublicId,
name: result.name,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,

View File

@@ -1,17 +1,18 @@
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,
@@ -19,8 +20,11 @@ import {
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
import {
generateUID,
generateWorkspacePrefix,
getDefaultPermissions,
} from "@kan/shared";
import * as permissionRepo from "./permission.repo";
@@ -30,22 +34,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
@@ -75,8 +79,12 @@ 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,
@@ -258,8 +266,12 @@ 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(
@@ -283,6 +295,7 @@ 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
@@ -365,6 +378,14 @@ 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,
@@ -373,6 +394,8 @@ export const searchBoardsAndCards = async (
) => {
const searchQuery = `%${query}%`;
const ticketId = parseTicketId(query.trim());
// Search for boards
const boardResults = await db
.select({
@@ -400,25 +423,19 @@ export const searchBoardsAndCards = async (
sql`similarity(${boards.name}, ${query}) DESC`,
desc(boards.updatedAt),
)
.limit(Math.ceil(limit * 0.4));
.limit(ticketId ? 0 : Math.ceil(limit * 0.4));
// Search for cards
const cardResults = await db
.select({
publicId: cards.publicId,
title: cards.title,
description: cards.description,
boardPublicId: boards.publicId,
boardName: boards.name,
listName: lists.name,
updatedAt: cards.updatedAt,
createdAt: cards.createdAt,
})
.from(cards)
.innerJoin(lists, eq(cards.listId, lists.id))
.innerJoin(boards, eq(lists.boardId, boards.id))
.where(
and(
// 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),
@@ -427,19 +444,46 @@ export const searchBoardsAndCards = async (
isNull(cards.deletedAt),
isNull(lists.deletedAt),
isNull(boards.deletedAt),
),
)
);
const cardResults = await db
.select({
publicId: cards.publicId,
title: cards.title,
description: cards.description,
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(
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
sql`similarity(${cards.title}, ${query}) DESC`,
desc(cards.updatedAt),
...(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),
]),
)
.limit(Math.floor(limit * 0.6));
.limit(ticketId ? 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,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
index,
integer,
pgEnum,
pgTable,
@@ -54,27 +55,36 @@ 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(),
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 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 cardsRelations = relations(cards, ({ one, many }) => ({
createdBy: one(users, {

View File

@@ -3,6 +3,7 @@ import {
bigint,
bigserial,
boolean,
index,
integer,
pgEnum,
pgTable,
@@ -38,25 +39,31 @@ 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),
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 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 workspaceRelations = relations(workspaces, ({ one, many }) => ({
user: one(users, {

View File

@@ -0,0 +1,26 @@
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,5 +1,6 @@
export * from "./generateUID";
export * from "./generateSlug";
export * from "./generateWorkspacePrefix";
export * from "./subscriptions";
export * from "./email";
export * from "./dueDateFilters";