Compare commits
17 Commits
security/a
...
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 |
@@ -2,7 +2,7 @@
|
||||
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
# Fill if you want to use an external database
|
||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||
@@ -28,12 +28,14 @@ S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
|
||||
@@ -203,6 +203,7 @@ pnpm dev
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
|
||||
@@ -39,10 +39,10 @@ checksums:
|
||||
0xWkkH/placeholders/boardCount/0: 8cc7c9ba5252e9266c6eb8434fae00f3
|
||||
0xWkkH/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
0xWkkH/translation: 72358c550bd1a99fcb6e8952f0f947ef
|
||||
yndBF%2B/translation: 9fb995d992256a9db7822d430ad75ba0
|
||||
yndBF%2B/message: 9fb995d992256a9db7822d430ad75ba0
|
||||
yndBF%2B/placeholders/projectCount/0: 23257c47018d21aeae42cf5bd0aa9932
|
||||
yndBF%2B/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
yndBF%2B/translation: 9fb995d992256a9db7822d430ad75ba0
|
||||
9lFfqv/message: 6cb28ff89203e2eca3fdd69396dd00d6
|
||||
9lFfqv/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
9lFfqv/translation: 6cb28ff89203e2eca3fdd69396dd00d6
|
||||
@@ -70,6 +70,9 @@ checksums:
|
||||
gkxGkF/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
gkxGkF/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
gkxGkF/translation: f5f537ac080c6184864ffaae7a9fcf4f
|
||||
4obmDr/message: 3d918420983fcb0d66a6b3df89a5294e
|
||||
4obmDr/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
4obmDr/translation: 3d918420983fcb0d66a6b3df89a5294e
|
||||
"%2Frn4RE/message": d405b83b0d631cb72f4347c10bcbb643
|
||||
"%2Frn4RE/origin/0/0": 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
"%2Frn4RE/translation": d405b83b0d631cb72f4347c10bcbb643
|
||||
@@ -87,6 +90,8 @@ checksums:
|
||||
7L01XJ/translation: c46571856723b03262fd33f511116298
|
||||
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
|
||||
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
|
||||
F6pfE9/origin/2/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
F6pfE9/translation: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
XJOV1Y/message: 1948763de8e531483a798b68195e297e
|
||||
XJOV1Y/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -128,6 +133,9 @@ checksums:
|
||||
pBsoKL/message: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
pBsoKL/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
pBsoKL/translation: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
cWXW%2B7/message: f8fa42d8c95936075e05ad238fd03467
|
||||
cWXW%2B7/origin/0/0: 566b5982085ae649b2be0c614f666591
|
||||
cWXW%2B7/translation: f8fa42d8c95936075e05ad238fd03467
|
||||
bmXKoX/message: 42b0488d570626dc887c58ff669c953c
|
||||
bmXKoX/placeholders/0/0: ea5a6955d34117338ea90173f2aa1f02
|
||||
bmXKoX/placeholders/labelList/0: 19a2dd6c58e803b03e6c301dc363eed6
|
||||
@@ -215,12 +223,12 @@ checksums:
|
||||
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
|
||||
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
|
||||
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
|
||||
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
|
||||
Dz63YI/message: 8e43abcd6e039b51084937e4829f58ad
|
||||
Dz63YI/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
|
||||
WmPhYW/message: 0aa3973b860c1faf8d9123aebf567e40
|
||||
WmPhYW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
WmPhYW/translation: 0aa3973b860c1faf8d9123aebf567e40
|
||||
@@ -268,6 +276,10 @@ checksums:
|
||||
lo8xBK/placeholders/entityLabel/0: 5745fa1af326b9ac2b140cbbef90cdeb
|
||||
lo8xBK/origin/0/0: 68450df287fb46ea629e818732c1291a
|
||||
lo8xBK/translation: f9b67026c5ef86ac4d5944380d571961
|
||||
Ypy5pZ/message: e4f0c7b9475b179d3856c0fe28aa4f21
|
||||
Ypy5pZ/placeholders/webhookName/0: b1f7ad11b42fd7d0142b9ae1fd269e35
|
||||
Ypy5pZ/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
Ypy5pZ/translation: e4f0c7b9475b179d3856c0fe28aa4f21
|
||||
BhDZlc/message: 27ba2854202dd77c51e78c64e5c93946
|
||||
BhDZlc/placeholders/0/0: 7c9ad8179019e516873e06573980408a
|
||||
BhDZlc/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
@@ -514,8 +526,9 @@ checksums:
|
||||
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/12/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/13/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
|
||||
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
|
||||
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -619,6 +632,9 @@ checksums:
|
||||
tOZp9v/message: 3e67f6c507a47ec6e6dd232a34c166ad
|
||||
tOZp9v/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
tOZp9v/translation: 3e67f6c507a47ec6e6dd232a34c166ad
|
||||
t0dTPm/message: 95ec8431a14c39ac2ab308bef3297a53
|
||||
t0dTPm/origin/0/0: 566b5982085ae649b2be0c614f666591
|
||||
t0dTPm/translation: 95ec8431a14c39ac2ab308bef3297a53
|
||||
"%2Ff3t6v/message": 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
"%2Ff3t6v/origin/0/0": 0dccc39d0debbf3e089cb6d292de762b
|
||||
"%2Ff3t6v/translation": 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
@@ -628,21 +644,21 @@ checksums:
|
||||
479pdJ/message: a0d2935d7b63f8dd19d7c0de47524416
|
||||
479pdJ/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
479pdJ/translation: a0d2935d7b63f8dd19d7c0de47524416
|
||||
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
|
||||
iSLIjg/message: 8778ee245078a8be4a2ce855c8c56edc
|
||||
iSLIjg/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
|
||||
3jod3l/message: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
3jod3l/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
nk7caK/message: 4440a0b9e387ef7136e3958e7a089213
|
||||
nk7caK/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
nk7caK/translation: 4440a0b9e387ef7136e3958e7a089213
|
||||
sDLCvT/message: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
sDLCvT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
sDLCvT/translation: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
MCIXdZ/translation: ad3738e5eeabcebd3478cdc2545f543f
|
||||
MCIXdZ/message: ad3738e5eeabcebd3478cdc2545f543f
|
||||
MCIXdZ/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
MCIXdZ/translation: ad3738e5eeabcebd3478cdc2545f543f
|
||||
5eZwRW/message: 84a510a7485f43e43f2191bd161171d7
|
||||
5eZwRW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
5eZwRW/translation: 84a510a7485f43e43f2191bd161171d7
|
||||
@@ -729,10 +745,16 @@ checksums:
|
||||
A5hiCy/message: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
A5hiCy/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
A5hiCy/translation: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
SiPp29/message: 04816e0980ed5debb74fb92169766590
|
||||
SiPp29/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
SiPp29/translation: 04816e0980ed5debb74fb92169766590
|
||||
FdtSNC/message: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
FdtSNC/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
FdtSNC/origin/1/0: 33acddc2fe10746463583d03146e7956
|
||||
FdtSNC/translation: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
d%2BF6q9/message: 6045b4ff97fb7dca19ebadfa2e34a700
|
||||
d%2BF6q9/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
d%2BF6q9/translation: 6045b4ff97fb7dca19ebadfa2e34a700
|
||||
f8lDFq/message: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
f8lDFq/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
f8lDFq/translation: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
@@ -801,6 +823,8 @@ checksums:
|
||||
cnGeoo/origin/4/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
cnGeoo/origin/5/0: d55f532bb5ae247e3a018978be0da463
|
||||
cnGeoo/origin/6/0: 4b4f55c033add8db69178563832227c6
|
||||
cnGeoo/origin/7/0: a1fde3fcce608870830f168f027727ff
|
||||
cnGeoo/origin/8/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
cnGeoo/translation: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
ZDGm40/message: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
ZDGm40/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
@@ -822,6 +846,9 @@ checksums:
|
||||
DFjdv0/message: bdf3e8342f581d498b7238cdd4897a4b
|
||||
DFjdv0/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
DFjdv0/translation: bdf3e8342f581d498b7238cdd4897a4b
|
||||
snMaH4/message: f15f64622d956beb69dce8ec24f237ae
|
||||
snMaH4/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
snMaH4/translation: f15f64622d956beb69dce8ec24f237ae
|
||||
kYu0eF/message: 9017c821209528d9b20d40a488854cdf
|
||||
kYu0eF/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
kYu0eF/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
@@ -844,9 +871,9 @@ checksums:
|
||||
f8fH8W/message: 991b75727b6784c1a063a7462b76186d
|
||||
f8fH8W/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
f8fH8W/translation: 991b75727b6784c1a063a7462b76186d
|
||||
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
Odv3J6/message: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
Odv3J6/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
YBBifR/message: a2dd737d133887d34504728c7e7f05e3
|
||||
YBBifR/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
YBBifR/translation: a2dd737d133887d34504728c7e7f05e3
|
||||
@@ -918,6 +945,7 @@ checksums:
|
||||
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
|
||||
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
|
||||
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
ePK91l/translation: eee7f39ff90b18852afc1671f21fbaa9
|
||||
tIdipJ/message: d8276dfc0189f371ec7d80a2047c07aa
|
||||
tIdipJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
@@ -933,6 +961,9 @@ checksums:
|
||||
TCOMOO/origin/0/0: afee530613110e248ffde6b56872ebed
|
||||
TCOMOO/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
TCOMOO/translation: 244558dd716491b7ed72ba8ab73aa28f
|
||||
fW5sSv/message: 145e9993fc334ea0718882c177928c01
|
||||
fW5sSv/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
fW5sSv/translation: 145e9993fc334ea0718882c177928c01
|
||||
klpSmb/message: bbae5f2f8a442947d33099979bbbe899
|
||||
klpSmb/origin/0/0: 2e937f7560901c3de71a592b4fd80f34
|
||||
klpSmb/translation: bbae5f2f8a442947d33099979bbbe899
|
||||
@@ -966,6 +997,9 @@ checksums:
|
||||
V8YWP3/message: f002074db0bd51d4f28d2736e140370a
|
||||
V8YWP3/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
V8YWP3/translation: f002074db0bd51d4f28d2736e140370a
|
||||
rQRXo8/message: 35ee0f41d7713e4497a4237db9a20264
|
||||
rQRXo8/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
rQRXo8/translation: 35ee0f41d7713e4497a4237db9a20264
|
||||
fR9laE/message: bfceabde4c0b6f2cb439015b76549651
|
||||
fR9laE/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
fR9laE/translation: bfceabde4c0b6f2cb439015b76549651
|
||||
@@ -996,9 +1030,9 @@ checksums:
|
||||
55xJ%2FO/message: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
55xJ%2FO/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
55xJ%2FO/translation: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
yhLUU8/translation: e879ffdeccbe5b00fa7ece09114d149f
|
||||
yhLUU8/message: e879ffdeccbe5b00fa7ece09114d149f
|
||||
yhLUU8/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
yhLUU8/translation: e879ffdeccbe5b00fa7ece09114d149f
|
||||
cji2nM/message: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
cji2nM/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
cji2nM/translation: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
@@ -1014,9 +1048,9 @@ checksums:
|
||||
9s9O5h/message: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
9s9O5h/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
9s9O5h/translation: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
gQ%2F02p/translation: 08e8dfeafa38796abcdcc258917428d6
|
||||
gQ%2F02p/message: 08e8dfeafa38796abcdcc258917428d6
|
||||
gQ%2F02p/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
gQ%2F02p/translation: 08e8dfeafa38796abcdcc258917428d6
|
||||
lKAvEd/message: a8766853864eddc480113778a708871b
|
||||
lKAvEd/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
lKAvEd/translation: a8766853864eddc480113778a708871b
|
||||
@@ -1047,6 +1081,10 @@ checksums:
|
||||
CYWHT%2B/origin/0/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
CYWHT%2B/origin/1/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
CYWHT%2B/translation: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
tst44n/message: d17179a947e9687dd2338ca524b26b63
|
||||
tst44n/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
tst44n/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
tst44n/translation: d17179a947e9687dd2338ca524b26b63
|
||||
8iP0S8/message: 62b44c4973b92b806c69a4b15e0256dc
|
||||
8iP0S8/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
8iP0S8/translation: 62b44c4973b92b806c69a4b15e0256dc
|
||||
@@ -1069,6 +1107,12 @@ checksums:
|
||||
53QYh8/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
53QYh8/origin/1/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
53QYh8/translation: a746e2afe881c3bf0a8e82a931ca3495
|
||||
o4sQAg/message: 97fd378ebf70814753414b035fe5639e
|
||||
o4sQAg/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
o4sQAg/translation: 97fd378ebf70814753414b035fe5639e
|
||||
9YPBHv/message: 87429c06e5895397f325c4087378050a
|
||||
9YPBHv/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
9YPBHv/translation: 87429c06e5895397f325c4087378050a
|
||||
W8oT%2BL/message: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
W8oT%2BL/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
W8oT%2BL/translation: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
@@ -1076,6 +1120,13 @@ checksums:
|
||||
YtUfwW/placeholders/0/0: 8085cc5670186c803eac6c957d57ea21
|
||||
YtUfwW/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
YtUfwW/translation: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
5ntVtp/message: d3052fcc32ff4f12415ea6687a97303e
|
||||
5ntVtp/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
5ntVtp/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
5ntVtp/translation: d3052fcc32ff4f12415ea6687a97303e
|
||||
R41XLH/message: 65f12e9667e1a3c968541fe96ffbf7c7
|
||||
R41XLH/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
R41XLH/translation: 65f12e9667e1a3c968541fe96ffbf7c7
|
||||
ZL1A%2Bp/message: f8a50d1c8491404f73d3cf701e11f297
|
||||
ZL1A%2Bp/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
ZL1A%2Bp/translation: f8a50d1c8491404f73d3cf701e11f297
|
||||
@@ -1184,12 +1235,12 @@ checksums:
|
||||
RkXlPZ/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
RkXlPZ/origin/1/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
RkXlPZ/translation: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
|
||||
PVT7q7/message: 8fa30040ec891db0b5b7d893786514ab
|
||||
PVT7q7/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
|
||||
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
|
||||
"%2FbBVaD/message": 79b9a6153d695ff0175aa804b1c0286b
|
||||
"%2FbBVaD/origin/0/0": 9d4260644f1a12f41d6b72574eeb111c
|
||||
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
|
||||
7eMo%2BU/message: 6251589da1964d55afabdfd64c84c335
|
||||
7eMo%2BU/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
7eMo%2BU/translation: 6251589da1964d55afabdfd64c84c335
|
||||
@@ -1199,7 +1250,11 @@ checksums:
|
||||
UveK6V/translation: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
mgXgHu/message: dc1362de207d67fe84fb0caf60262e73
|
||||
mgXgHu/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
mgXgHu/origin/1/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
mgXgHu/translation: dc1362de207d67fe84fb0caf60262e73
|
||||
XEoGkQ/message: 1cdeef0fc0f3ab18a88f34a557032582
|
||||
XEoGkQ/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
XEoGkQ/translation: 1cdeef0fc0f3ab18a88f34a557032582
|
||||
KAsRdK/message: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
KAsRdK/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
KAsRdK/translation: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
@@ -1226,6 +1281,9 @@ checksums:
|
||||
XXbgHS/message: e5a9b1bd409b007141fe3d7890022f9a
|
||||
XXbgHS/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
XXbgHS/translation: e5a9b1bd409b007141fe3d7890022f9a
|
||||
SRvxId/message: e356e36ab5a3e30e81f132dd9abb5483
|
||||
SRvxId/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
SRvxId/translation: e356e36ab5a3e30e81f132dd9abb5483
|
||||
LrcnbT/message: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
LrcnbT/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
LrcnbT/translation: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
@@ -1293,6 +1351,9 @@ checksums:
|
||||
WbTDwg/origin/3/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
WbTDwg/origin/4/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
WbTDwg/translation: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
NoNwIX/message: 239e3b722db75bbafc6c628a2685eed2
|
||||
NoNwIX/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NoNwIX/translation: 239e3b722db75bbafc6c628a2685eed2
|
||||
6mbe4b/message: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
6mbe4b/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
6mbe4b/translation: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
@@ -1476,6 +1537,9 @@ checksums:
|
||||
"%2FbZzdR/message": 40ea106c56d79634e058e087ad322025
|
||||
"%2FbZzdR/origin/0/0": c0b59968b2b234167c488a05cc38710b
|
||||
"%2FbZzdR/translation": 40ea106c56d79634e058e087ad322025
|
||||
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
|
||||
hty0d5/message: b4b1c8396e445e8b7956c747da2cf601
|
||||
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
|
||||
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
|
||||
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
|
||||
@@ -1491,10 +1555,15 @@ checksums:
|
||||
T7LJic/message: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
T7LJic/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
T7LJic/translation: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
uEGGDs/message: 844d23d8391d57110f8b03a14a35cf00
|
||||
uEGGDs/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
uEGGDs/translation: 844d23d8391d57110f8b03a14a35cf00
|
||||
6YtxFj/message: 9368b5a047572b6051f334af5aa76819
|
||||
6YtxFj/origin/0/0: d40778923d288b8a81f4cabac4898d43
|
||||
6YtxFj/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
6YtxFj/origin/2/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
6YtxFj/origin/3/0: f64cd62b620f454998523af2a4330278
|
||||
6YtxFj/origin/4/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
6YtxFj/translation: 9368b5a047572b6051f334af5aa76819
|
||||
UxKoFf/message: 0373afd8238db1c49f4be4fa6cdf5cd3
|
||||
UxKoFf/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
@@ -1541,6 +1610,9 @@ checksums:
|
||||
RJA%2Bsg/message: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
RJA%2Bsg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
RJA%2Bsg/translation: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
sbNNyi/message: a9f91ae6676523cbda6e7373d97eaee5
|
||||
sbNNyi/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
sbNNyi/translation: a9f91ae6676523cbda6e7373d97eaee5
|
||||
curoK5/message: 8eecf05b4ce29aa880adfed2e11476af
|
||||
curoK5/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
curoK5/translation: 8eecf05b4ce29aa880adfed2e11476af
|
||||
@@ -1581,9 +1653,9 @@ checksums:
|
||||
fvLNDy/message: f18ee3d7230cc33b68bd17b429d1d442
|
||||
fvLNDy/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
fvLNDy/translation: f18ee3d7230cc33b68bd17b429d1d442
|
||||
i30J2U/translation: 8b7ec59963fb26e13948600c55c8de1d
|
||||
i30J2U/message: 8b7ec59963fb26e13948600c55c8de1d
|
||||
i30J2U/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
i30J2U/translation: 8b7ec59963fb26e13948600c55c8de1d
|
||||
ERpq2P/message: 5db6294712528cd897b15ae36f4fd834
|
||||
ERpq2P/placeholders/debouncedQuery/0: 8471823afb4210cf564ca0d8bef9dd66
|
||||
ERpq2P/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
@@ -1591,6 +1663,9 @@ checksums:
|
||||
Q9pQaX/message: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
Q9pQaX/origin/0/0: 7f5f72488c743ec0795cf10058ce3351
|
||||
Q9pQaX/translation: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
TX0bT7/message: a7afbc2487022f17b6d993f556fd2735
|
||||
TX0bT7/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
TX0bT7/translation: a7afbc2487022f17b6d993f556fd2735
|
||||
9h7RDh/message: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
9h7RDh/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
9h7RDh/translation: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
@@ -1640,6 +1715,9 @@ checksums:
|
||||
SPLN9O/message: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
SPLN9O/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
SPLN9O/translation: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
8F1i42/message: 97612e6230bc7a1ebd99380bf561b732
|
||||
8F1i42/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
8F1i42/translation: 97612e6230bc7a1ebd99380bf561b732
|
||||
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
|
||||
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
|
||||
@@ -1718,6 +1796,9 @@ checksums:
|
||||
7b2yuC/message: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
7b2yuC/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
7b2yuC/translation: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
jEw0Mr/message: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
jEw0Mr/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
jEw0Mr/translation: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
tmlJN1/message: c16c69c3b742b1e19148378d50adf37f
|
||||
tmlJN1/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
tmlJN1/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
@@ -1932,11 +2013,17 @@ checksums:
|
||||
YEOVrT/message: 86b76024524fc585b2c3950126ef6f62
|
||||
YEOVrT/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
YEOVrT/translation: 86b76024524fc585b2c3950126ef6f62
|
||||
"%2B5kO8P/translation": 77b65adf8ae2b69eaed51b6104ded65e
|
||||
"%2B5kO8P/message": 77b65adf8ae2b69eaed51b6104ded65e
|
||||
"%2B5kO8P/origin/0/0": 04c86ea96f180745739d59e822da4026
|
||||
tfDRzk/message: f7a2929f33bc420195e59ac5a8bcd454
|
||||
tfDRzk/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
tfDRzk/origin/1/0: b2059fa4b8b99cc10440da26475b234c
|
||||
tfDRzk/origin/2/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tfDRzk/translation: f7a2929f33bc420195e59ac5a8bcd454
|
||||
y3aU20/message: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
y3aU20/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
y3aU20/translation: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
MdwUGG/message: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
MdwUGG/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
MdwUGG/translation: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
@@ -1946,6 +2033,12 @@ checksums:
|
||||
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
||||
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
||||
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
||||
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
||||
OkTY4%2B/message: 5deb2ff7c984db2d69457d24c3dd7d0a
|
||||
OkTY4%2B/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
OkTY4%2B/translation: 5deb2ff7c984db2d69457d24c3dd7d0a
|
||||
a3LDKx/message: 4b34923fef858a2b9a4a914c3e822889
|
||||
a3LDKx/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
a3LDKx/translation: 4b34923fef858a2b9a4a914c3e822889
|
||||
@@ -1956,6 +2049,9 @@ checksums:
|
||||
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/translation: eedc7cdb02de467c15dc418a066a77f2
|
||||
o4e%2F70/message: d39f10456ad4ef683c1d0161e9b87521
|
||||
o4e%2F70/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
o4e%2F70/translation: d39f10456ad4ef683c1d0161e9b87521
|
||||
rYUZIe/message: c7b0ec447415ff62856803a4d7838bd6
|
||||
rYUZIe/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
rYUZIe/translation: c7b0ec447415ff62856803a4d7838bd6
|
||||
@@ -1971,6 +2067,9 @@ checksums:
|
||||
RoafuO/message: 9631cc08d49da04475b30a0d320ce97c
|
||||
RoafuO/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
RoafuO/translation: 9631cc08d49da04475b30a0d320ce97c
|
||||
eus61c/message: fb230b27a8a91d7e597d6579466246b8
|
||||
eus61c/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
eus61c/translation: fb230b27a8a91d7e597d6579466246b8
|
||||
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
@@ -1999,6 +2098,9 @@ checksums:
|
||||
F%2FFPk%2F/message: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
F%2FFPk%2F/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
F%2FFPk%2F/translation: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
"%2Bh2faV/message": 402ab0ca17c732457498d09a83dbe8ce
|
||||
"%2Bh2faV/origin/0/0": 566b5982085ae649b2be0c614f666591
|
||||
"%2Bh2faV/translation": 402ab0ca17c732457498d09a83dbe8ce
|
||||
Ci%2FKUX/message: 5d0bacf7ff696da940f232df45edfd39
|
||||
Ci%2FKUX/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
Ci%2FKUX/translation: 5d0bacf7ff696da940f232df45edfd39
|
||||
@@ -2056,6 +2158,9 @@ checksums:
|
||||
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
||||
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
||||
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
||||
uAQUqI/message: 4e1fcce15854d824919b4a582c697c90
|
||||
uAQUqI/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
uAQUqI/translation: 4e1fcce15854d824919b4a582c697c90
|
||||
WYDptz/message: 05f2b4abfc36def17756a6969983cf86
|
||||
WYDptz/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
WYDptz/translation: 05f2b4abfc36def17756a6969983cf86
|
||||
@@ -2063,6 +2168,9 @@ checksums:
|
||||
zzDlyQ/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
zzDlyQ/origin/1/0: d96af8a06c2e38a72238bcd16e426859
|
||||
zzDlyQ/translation: c43827becada6750f7a25890905f38b9
|
||||
DBC3t5/translation: d21934fb36dd8a82e36352968207d44e
|
||||
DBC3t5/message: d21934fb36dd8a82e36352968207d44e
|
||||
DBC3t5/origin/0/0: 04c86ea96f180745739d59e822da4026
|
||||
uZg2%2Bw/message: 21af8119141afcce5f12da489f34db6a
|
||||
uZg2%2Bw/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
uZg2%2Bw/translation: 21af8119141afcce5f12da489f34db6a
|
||||
@@ -2108,6 +2216,13 @@ checksums:
|
||||
UCLeG0/message: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
UCLeG0/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
UCLeG0/translation: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
|
||||
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
|
||||
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
|
||||
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
itgYbC/translation: efe14181bc517316e3ab3258d72d2ff9
|
||||
VUZDlr/message: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
VUZDlr/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
|
||||
VUZDlr/translation: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
@@ -2136,6 +2251,9 @@ checksums:
|
||||
gUX7b%2B/message: 692cb2e8a8e610953c996826beed45a2
|
||||
gUX7b%2B/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
gUX7b%2B/translation: 692cb2e8a8e610953c996826beed45a2
|
||||
0xD8Of/message: 65ad307ba26227fe44343f17e52138de
|
||||
0xD8Of/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
0xD8Of/translation: 65ad307ba26227fe44343f17e52138de
|
||||
fQCrjt/message: 970e17a115f7374e60e0a8ded425db8f
|
||||
fQCrjt/placeholders/0/0: ed822bd0ada0a2e8d894cac37b70c346
|
||||
fQCrjt/origin/0/0: d6920b20329a9efe5985a39a295d83e1
|
||||
@@ -2196,9 +2314,9 @@ checksums:
|
||||
4gAX8s/message: 29dea3e0b6238874f8c7a27619df8e36
|
||||
4gAX8s/origin/0/0: 634473ef965836871efe40875a36b6ab
|
||||
4gAX8s/translation: 29dea3e0b6238874f8c7a27619df8e36
|
||||
L5WQLg/translation: 38a978eab728978612d1dee8bdeb915b
|
||||
L5WQLg/message: 38a978eab728978612d1dee8bdeb915b
|
||||
L5WQLg/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
L5WQLg/translation: 38a978eab728978612d1dee8bdeb915b
|
||||
eEQyz%2B/message: 0d3bac559c71ec4b8734f9f212320de5
|
||||
eEQyz%2B/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
eEQyz%2B/translation: 0d3bac559c71ec4b8734f9f212320de5
|
||||
@@ -2418,10 +2536,17 @@ checksums:
|
||||
BBUDfW/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
BBUDfW/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
BBUDfW/translation: e79afcd4398e52e10e7e8243a7eecabe
|
||||
IagCbF/message: ca97457614226960d41dd18c3c29c86b
|
||||
IagCbF/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
IagCbF/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
IagCbF/translation: ca97457614226960d41dd18c3c29c86b
|
||||
MI5OBT/message: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
MI5OBT/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
MI5OBT/origin/1/0: 30791e26a172d47d3cb0693e82679246
|
||||
MI5OBT/translation: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
7aONWr/message: 96af2ad24e17a1fe98a79e0a140a1d11
|
||||
7aONWr/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
7aONWr/translation: 96af2ad24e17a1fe98a79e0a140a1d11
|
||||
i5rE2%2F/message: 7f3c9ded3f8b8765809ac7dd9505c0b0
|
||||
i5rE2%2F/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
i5rE2%2F/origin/1/0: 30791e26a172d47d3cb0693e82679246
|
||||
@@ -2433,6 +2558,9 @@ checksums:
|
||||
QNtP5M/message: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
QNtP5M/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
QNtP5M/translation: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
n6EWYz/message: dc868fbeff71dc275c8b0635c9f97a5a
|
||||
n6EWYz/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
n6EWYz/translation: dc868fbeff71dc275c8b0635c9f97a5a
|
||||
7PzzBU/message: 61073457a5c3901084b557d065f876be
|
||||
7PzzBU/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
7PzzBU/translation: 61073457a5c3901084b557d065f876be
|
||||
@@ -2472,6 +2600,35 @@ checksums:
|
||||
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
|
||||
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
|
||||
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
|
||||
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
|
||||
jIuWsV/message: 003dddfd1d3ed7c94ec0027e5bb9d496
|
||||
jIuWsV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
jIuWsV/translation: 003dddfd1d3ed7c94ec0027e5bb9d496
|
||||
U2%2Brn0/message: b94fc9ad83b267fcccacd0f4c529148b
|
||||
U2%2Brn0/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
U2%2Brn0/translation: b94fc9ad83b267fcccacd0f4c529148b
|
||||
8iP9oQ/message: 5c07446c838d07572c6b40621507e951
|
||||
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
|
||||
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
|
||||
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
|
||||
Hf1cEZ/message: 4b2d60dd7d86dafbf32155a5246a1d40
|
||||
Hf1cEZ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
Hf1cEZ/translation: 4b2d60dd7d86dafbf32155a5246a1d40
|
||||
v1kQyJ/message: 0329bc102d3ab3c73410d126e119de12
|
||||
v1kQyJ/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
|
||||
v1kQyJ/origin/1/0: 566b5982085ae649b2be0c614f666591
|
||||
v1kQyJ/translation: 0329bc102d3ab3c73410d126e119de12
|
||||
xLTZVf/translation: ce928c74255344905f5c3f02f44c991e
|
||||
xLTZVf/message: ce928c74255344905f5c3f02f44c991e
|
||||
xLTZVf/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
9eF5oV/message: 4928884739ba559e6e4b960e80fe1452
|
||||
9eF5oV/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
9eF5oV/translation: 4928884739ba559e6e4b960e80fe1452
|
||||
@@ -2615,24 +2772,24 @@ checksums:
|
||||
tca56%2F/message: 19d84e934877efc0fef285ff3d6e7849
|
||||
tca56%2F/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
tca56%2F/translation: 19d84e934877efc0fef285ff3d6e7849
|
||||
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
|
||||
HcT8J4/message: 19478f9152e6879c9c029198e635b44d
|
||||
HcT8J4/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
|
||||
AdxYds/message: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
AdxYds/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
|
||||
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
|
||||
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
|
||||
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
|
||||
"%2BjbXzb/translation": c07e27183a52361fc05b919c2a3f5c1f
|
||||
"%2BjbXzb/message": c07e27183a52361fc05b919c2a3f5c1f
|
||||
"%2BjbXzb/origin/0/0": 3c57a918258af4b44c187f58d203345f
|
||||
"%2BjbXzb/translation": c07e27183a52361fc05b919c2a3f5c1f
|
||||
CJWDTP/message: 126aeeb73f7e4cad338056e4c933f39b
|
||||
CJWDTP/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
CJWDTP/translation: 126aeeb73f7e4cad338056e4c933f39b
|
||||
|
||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
39
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"background_color": "#000000",
|
||||
"categories": [
|
||||
"business",
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"description": "The open source Trello alternative.",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"sizes": "512x512",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"id": "/",
|
||||
"screenshots": [
|
||||
{
|
||||
"sizes": "1624x1561",
|
||||
"src": "/screenshot-wide.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
},
|
||||
{
|
||||
"sizes": "1290x2796",
|
||||
"src": "/screenshot-narrow.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"orientation": "portrait-primary",
|
||||
"name": "Kan.bn | The open source alternative to Trello",
|
||||
"scope": "/",
|
||||
"short_name": "Kan.bn",
|
||||
"start_url": "/",
|
||||
"theme_color": "#000000"
|
||||
}
|
||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const DateSelector = ({
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
weekStartsOn = 1,
|
||||
}: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, []);
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate]);
|
||||
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
|
||||
@@ -111,6 +111,7 @@ export function NewWorkspaceForm() {
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
|
||||
@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiChevronDown,
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineBolt,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
@@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
label: t`API`,
|
||||
condition: true,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
icon: <HiOutlineBolt />,
|
||||
label: t`Webhooks`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
|
||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
export function useScrollRestore(
|
||||
boardId: string | null | undefined,
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
router: NextRouter,
|
||||
isReady: boolean,
|
||||
) {
|
||||
const restored = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardId) return;
|
||||
|
||||
restored.current = false;
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", saveScrollPosition);
|
||||
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||
}, [boardId, router.events, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restored.current || !isReady || !boardId) return;
|
||||
restored.current = true;
|
||||
|
||||
const saved = scrollPositions.get(boardId);
|
||||
if (saved === undefined) return;
|
||||
|
||||
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||
});
|
||||
});
|
||||
}, [isReady, scrollRef, boardId]);
|
||||
}
|
||||
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
55
apps/web/src/pages/404.tsx
Normal file
55
apps/web/src/pages/404.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
404
|
||||
</p>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||
<Trans>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||
>
|
||||
<Trans>Go to homepage</Trans>
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
<Trans>Go to boards</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { url, filename } = req.query;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({
|
||||
message: "url parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename = typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
message: "Failed to fetch attachment",
|
||||
});
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
const { url, filename } = req.query;
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({ message: "url parameter is required" });
|
||||
}
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
const s3Endpoint = env.S3_ENDPOINT;
|
||||
|
||||
if (s3Endpoint) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ message: "Invalid URL" });
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
let allowedHost: string;
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
return res.status(403).json({ message: "URL not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename =
|
||||
typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json({ message: "Failed to fetch attachment" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
|
||||
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import WebhookSettings from "~/views/settings/WebhookSettings";
|
||||
|
||||
const WebhookSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="webhooks">
|
||||
<WebhookSettings />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default WebhookSettingsPage;
|
||||
@@ -19,6 +19,7 @@ interface Workspace {
|
||||
slug: string | undefined;
|
||||
plan: "free" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -28,13 +29,14 @@ const initialWorkspace: Workspace = {
|
||||
slug: "",
|
||||
plan: "free",
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
|
||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
export const WorkspaceContext = createContext<
|
||||
WorkspaceContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
@@ -79,6 +81,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
slug: workspace.slug,
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -100,6 +103,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
@@ -119,6 +123,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
@@ -24,6 +24,7 @@ import Toggle from "~/components/Toggle";
|
||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -53,6 +54,7 @@ export function NewCardForm({
|
||||
queryParams,
|
||||
}: NewCardFormProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -491,6 +493,7 @@ export function NewCardForm({
|
||||
setValue("dueDate", date ?? null);
|
||||
setIsDateSelectorOpen(false);
|
||||
}}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -124,11 +125,21 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
data: boardData,
|
||||
isSuccess,
|
||||
isLoading: isQueryLoading,
|
||||
error,
|
||||
} = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Redirect to 404 if board doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && boardId && !isQueryLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, boardId, isQueryLoading, error, boardData]);
|
||||
|
||||
const refetchBoard = async () => {
|
||||
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
|
||||
};
|
||||
@@ -141,6 +152,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function DueDateSelector({
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
||||
@@ -135,6 +137,7 @@ export function DueDateSelector({
|
||||
<DateSelector
|
||||
selectedDate={pendingDate ?? undefined}
|
||||
onDateSelect={handleDateSelect}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -185,11 +185,20 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && cardId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, cardId, isLoading, error, card]);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function CardModal({
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
const { data, isLoading, error } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId: cardPublicId ?? "",
|
||||
},
|
||||
@@ -56,6 +56,17 @@ export function CardModal({
|
||||
},
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (isOpen && cardPublicId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading && error)) {
|
||||
// Close modal first, then redirect
|
||||
closeModal();
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [isOpen, cardPublicId, isLoading, error, data, closeModal, router]);
|
||||
|
||||
const labels = data?.labels ?? [];
|
||||
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
@@ -12,13 +13,22 @@ export default function PublicBoardsView() {
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const { data, isLoading } = api.workspace.bySlug.useQuery(
|
||||
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
|
||||
{
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
},
|
||||
{ enabled: !!workspaceSlug },
|
||||
);
|
||||
|
||||
// Redirect to 404 if workspace doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && workspaceSlug && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, workspaceSlug, isLoading, error, data]);
|
||||
|
||||
const BoardsList = ({
|
||||
isLoading,
|
||||
boards,
|
||||
|
||||
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal file
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
|
||||
import { NewWebhookModal } from "./components/NewWebhookModal";
|
||||
import WebhookList from "./components/WebhookList";
|
||||
|
||||
export default function WebhookSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Webhooks`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Webhooks`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
|
||||
{t`Add webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WebhookList workspacePublicId={workspace.publicId} />
|
||||
</div>
|
||||
|
||||
{/* Webhook-specific modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
|
||||
>
|
||||
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
|
||||
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() {
|
||||
@@ -86,6 +87,15 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Week start day`}
|
||||
</h2>
|
||||
<UpdateWeekStartDayForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
weekStartDay={workspaceData?.weekStartDay ?? 1}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface DeleteWebhookConfirmationProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
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" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
isLoading={deleteWebhookMutation.isPending}
|
||||
>
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal file
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const newWebhookSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook name is required` })
|
||||
.max(255, { message: t`Webhook name cannot exceed 255 characters` }),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook URL is required` })
|
||||
.url({ message: t`Please enter a valid URL` })
|
||||
.max(2048, { message: t`URL cannot exceed 2048 characters` }),
|
||||
secret: z
|
||||
.string()
|
||||
.max(512, { message: t`Secret cannot exceed 512 characters` })
|
||||
.optional(),
|
||||
events: z
|
||||
.array(z.enum(webhookEvents))
|
||||
.min(1, { message: t`Select at least one event` }),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
type WebhookFormData = z.infer<typeof newWebhookSchema>;
|
||||
|
||||
interface NewWebhookModalProps {
|
||||
workspacePublicId: string;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null;
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<WebhookFormData>({
|
||||
resolver: zodResolver(newWebhookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && webhookPublicId && modalState) {
|
||||
reset({
|
||||
name: modalState.name ?? "",
|
||||
url: modalState.url ?? "",
|
||||
secret: "",
|
||||
events: modalState.events ?? ["card.created"],
|
||||
active: modalState.active ?? true,
|
||||
});
|
||||
} else if (!isEdit) {
|
||||
reset({
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}, [isEdit, webhookPublicId, modalState, reset]);
|
||||
|
||||
// Clear modal state when closing
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isEdit) {
|
||||
clearModalState("EDIT_WEBHOOK");
|
||||
}
|
||||
};
|
||||
}, [isEdit, clearModalState]);
|
||||
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: WebhookFormData) => {
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
active: data.active,
|
||||
});
|
||||
} else {
|
||||
createWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWebhook = () => {
|
||||
if (!webhookPublicId) return;
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">
|
||||
{isEdit ? t`Edit webhook` : t`New webhook`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Name`}
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`My webhook`}
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
{...register("url")}
|
||||
errorMessage={errors.url?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Secret (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-dark-800">
|
||||
{t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Events`}
|
||||
</label>
|
||||
<Controller
|
||||
name="events"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value.includes(event)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<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">
|
||||
<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"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
{isEdit && webhookPublicId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestWebhook}
|
||||
isLoading={isTestingWebhook}
|
||||
>
|
||||
{t`Send test`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEdit ? t`Save changes` : t`Create webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWeekStartDayForm({
|
||||
workspacePublicId,
|
||||
weekStartDay,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
weekStartDay: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [value, setValue] = useState(weekStartDay);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(weekStartDay);
|
||||
}, [weekStartDay]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
void utils.workspace.all.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (disabled) return;
|
||||
const newValue = Number(e.target.value);
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || updateWorkspace.isPending}
|
||||
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
>
|
||||
<option value={0}>{t`Sunday`}</option>
|
||||
<option value={1}>{t`Monday`}</option>
|
||||
<option value={6}>{t`Saturday`}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal file
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { HiEllipsisHorizontal } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface TableRowProps {
|
||||
publicId?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
events?: string[];
|
||||
active?: boolean;
|
||||
createdAt?: Date | null;
|
||||
dateLocale?: DateFnsLocale;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
onEdit?: () => void;
|
||||
onTest?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function formatEvents(events: string[]) {
|
||||
return events
|
||||
.map((e) => e.replace("card.", ""))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatDate(date?: Date | null, locale?: DateFnsLocale) {
|
||||
if (!date) return "Never";
|
||||
return format(date, "MMM d, yyyy", { locale });
|
||||
}
|
||||
|
||||
function TableRow({
|
||||
publicId,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
active,
|
||||
createdAt,
|
||||
dateLocale,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
onEdit,
|
||||
onTest,
|
||||
onDelete,
|
||||
}: TableRowProps) {
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[25%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<div className="ml-2 min-w-0 flex-1">
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[30%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[180px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[20%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[100px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{events && formatEvents(events)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset",
|
||||
active
|
||||
? "bg-emerald-500/10 text-emerald-400 ring-emerald-500/20"
|
||||
: "bg-gray-500/10 text-gray-400 ring-gray-500/20",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (active ? t`Active` : t`Inactive`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{formatDate(createdAt, dateLocale)}
|
||||
</p>
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[5%] min-w-[50px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (
|
||||
<div className="flex w-full items-center justify-center px-3">
|
||||
<div className="relative z-50">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: () => onEdit?.(),
|
||||
},
|
||||
{
|
||||
label: t`Test`,
|
||||
action: () => onTest?.(),
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: () => onDelete?.(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookListProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const { openModal, setModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { dateLocale } = useLocalisation();
|
||||
|
||||
const { data: webhooks, isLoading } = api.webhook.list.useQuery({
|
||||
workspacePublicId,
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoading && (!webhooks || webhooks.length === 0)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-light-300 bg-light-50 p-8 text-center dark:border-dark-300 dark:bg-dark-100">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No webhooks configured. Add a webhook to receive notifications.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<div className="inline-block min-w-full py-2 pb-12 align-middle">
|
||||
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-[700px] divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[25%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
>
|
||||
{t`Name`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[30%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`URL`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Events`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Status`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Created`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{/* Actions column */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
webhooks?.map((webhook, index) => (
|
||||
<TableRow
|
||||
key={webhook.publicId}
|
||||
publicId={webhook.publicId}
|
||||
name={webhook.name}
|
||||
url={webhook.url}
|
||||
events={webhook.events}
|
||||
active={webhook.active}
|
||||
createdAt={webhook.createdAt}
|
||||
dateLocale={dateLocale}
|
||||
isLastRow={index === webhooks.length - 1}
|
||||
onEdit={() => {
|
||||
setModalState("EDIT_WEBHOOK", {
|
||||
publicId: webhook.publicId,
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
});
|
||||
openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
onTest={() => {
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhook.publicId,
|
||||
});
|
||||
}}
|
||||
onDelete={() => {
|
||||
openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton isLastRow />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,7 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- 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_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
|
||||
@@ -57,6 +57,7 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- 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_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
|
||||
75
packages/api/integration-tests/test-db.ts
Normal file
75
packages/api/integration-tests/test-db.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import * as schema from "@kan/db/schema";
|
||||
|
||||
export type TestDbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory PGlite database for testing.
|
||||
* Each call returns an isolated database instance with migrations applied.
|
||||
*/
|
||||
export async function createTestDb(): Promise<TestDbClient> {
|
||||
const client = new PGlite({
|
||||
extensions: { uuid_ossp, pg_trgm },
|
||||
});
|
||||
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
// Run migrations
|
||||
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
return db as unknown as TestDbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a test database with a workspace and user for testing.
|
||||
* Returns the created entities for use in tests.
|
||||
*/
|
||||
export async function seedTestData(db: TestDbClient) {
|
||||
// Create a test user
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create a test workspace (publicId must be exactly 12 chars)
|
||||
const [workspace] = await db
|
||||
.insert(schema.workspaces)
|
||||
.values({
|
||||
publicId: "wstest123456",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
ownerId: user!.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add user as admin member of workspace
|
||||
await db.insert(schema.workspaceMembers).values({
|
||||
publicId: "wm1234567890",
|
||||
email: user!.email,
|
||||
workspaceId: workspace!.id,
|
||||
userId: user!.id,
|
||||
createdBy: user!.id,
|
||||
role: "admin",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return { user: user!, workspace: workspace! };
|
||||
}
|
||||
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
|
||||
|
||||
describe("webhook repository integration tests", () => {
|
||||
let db: TestDbClient;
|
||||
let testUser: { id: string; name: string | null };
|
||||
let testWorkspace: { id: number; publicId: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb();
|
||||
const seeded = await seedTestData(db);
|
||||
testUser = seeded.user;
|
||||
testWorkspace = seeded.workspace;
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with all fields", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "my-secret",
|
||||
events: ["card.created", "card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("My Webhook");
|
||||
expect(webhook!.url).toBe("https://example.com/webhook");
|
||||
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
|
||||
expect(webhook!.active).toBe(true);
|
||||
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
|
||||
});
|
||||
|
||||
it("creates a webhook without secret", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "No Secret Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.deleted"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("No Secret Webhook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByPublicId", () => {
|
||||
it("retrieves a webhook by public ID", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.publicId).toBe(created!.publicId);
|
||||
expect(retrieved!.name).toBe("Test Webhook");
|
||||
});
|
||||
|
||||
it("returns null for non-existent public ID", async () => {
|
||||
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllByWorkspaceId", () => {
|
||||
it("returns all webhooks for a workspace", async () => {
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 1",
|
||||
url: "https://example.com/webhook1",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 2",
|
||||
url: "https://example.com/webhook2",
|
||||
events: ["card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toHaveLength(2);
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
|
||||
});
|
||||
|
||||
it("returns empty array for workspace with no webhooks", async () => {
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveByWorkspaceId", () => {
|
||||
it("returns only active webhooks", async () => {
|
||||
const active = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Active Webhook",
|
||||
url: "https://example.com/active",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const inactive = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Inactive Webhook",
|
||||
url: "https://example.com/inactive",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// Deactivate one webhook
|
||||
await webhookRepo.update(db, inactive!.publicId, { active: false });
|
||||
|
||||
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(activeWebhooks).toHaveLength(1);
|
||||
// getActiveByWorkspaceId returns only publicId, url, secret, events
|
||||
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
|
||||
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Original Name",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe("Updated Name");
|
||||
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
|
||||
});
|
||||
|
||||
it("updates webhook events", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
events: ["card.created", "card.updated", "card.deleted"],
|
||||
});
|
||||
|
||||
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
|
||||
});
|
||||
|
||||
it("updates webhook active status", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(created!.active).toBe(true);
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(updated!.active).toBe(false);
|
||||
});
|
||||
|
||||
it("sets updatedAt timestamp on update", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// create() doesn't return updatedAt, verify via getByPublicId
|
||||
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(initial!.updatedAt).toBeNull();
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated!.updatedAt).not.toBeNull();
|
||||
expect(updated!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null for non-existent webhook", async () => {
|
||||
const updated = await webhookRepo.update(db, "nonexistent12", {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hardDelete", () => {
|
||||
it("deletes a webhook permanently", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "To Be Deleted",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(db, created!.publicId);
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw for non-existent webhook", async () => {
|
||||
await expect(
|
||||
webhookRepo.hardDelete(db, "nonexistent12"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,8 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -60,7 +62,8 @@
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { webhookRouter } from "./routers/webhook";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
webhook: webhookRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
423
packages/api/src/routers/webhook.test.ts
Normal file
423
packages/api/src/routers/webhook.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getAllByWorkspaceId: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
hardDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/workspace.repo", () => ({
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/permissions", () => ({
|
||||
assertPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
|
||||
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
|
||||
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
|
||||
|
||||
// We need to import the router after mocks are set up
|
||||
// Testing approach: call the internal handler logic through a test wrapper
|
||||
describe("webhook router", () => {
|
||||
const mockDb = {} as never;
|
||||
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
|
||||
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
|
||||
const mockWebhook = {
|
||||
id: 1,
|
||||
publicId: "wh-123456789",
|
||||
workspaceId: 1,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "secret123",
|
||||
events: ["card.created", "card.updated"] as const,
|
||||
active: true,
|
||||
createdAt: new Date("2024-01-15"),
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertPermission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("authorization", () => {
|
||||
it("throws UNAUTHORIZED when user is not authenticated", async () => {
|
||||
// Import fresh to get mocked version
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const ctx = {
|
||||
user: null,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when workspace does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("checks workspace:manage permission via assertPermission", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
|
||||
|
||||
expect(mockAssertPermission).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
"workspace:manage",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("returns all webhooks for workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe("My Webhook");
|
||||
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it("returns empty array when no webhooks exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with valid input", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(result.name).toBe("New Webhook");
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
|
||||
workspaceId: mockWorkspace.id,
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
secret: undefined,
|
||||
events: ["card.created"],
|
||||
createdBy: mockUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a webhook with secret", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
secret: "my-secret-key",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
|
||||
secret: "my-secret-key",
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockUpdate.mockResolvedValueOnce(updatedWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
|
||||
name: "Updated Name",
|
||||
url: undefined,
|
||||
secret: undefined,
|
||||
events: undefined,
|
||||
active: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("deletes webhook successfully", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockHardDelete.mockResolvedValueOnce(undefined);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("sends test payload to webhook URL", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
mockWebhook.url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when test fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/api/src/routers/webhook.ts
Normal file
359
packages/api/src/routers/webhook.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import {
|
||||
webhookUrlSchema,
|
||||
sendWebhookToUrl,
|
||||
createCardWebhookPayload,
|
||||
} from "../utils/webhook";
|
||||
|
||||
const webhookEventSchema = z.enum(webhookEvents);
|
||||
|
||||
export const webhookRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get all webhooks for a workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Retrieves all webhooks configured for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Creates a new webhook for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255),
|
||||
url: webhookUrlSchema,
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const result = await webhookRepo.create(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to create webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a webhook",
|
||||
method: "PUT",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Updates a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
url: webhookUrlSchema.optional(),
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
active: input.active,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to update webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a webhook",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Deletes a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
test: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Test a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
|
||||
description: "Sends a test payload to a webhook",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
statusCode: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const testPayload = createCardWebhookPayload("card.created", {
|
||||
id: "test-card-id",
|
||||
title: "Test Card",
|
||||
description: "This is a test webhook payload",
|
||||
dueDate: null,
|
||||
listId: "test-list-id",
|
||||
}, {
|
||||
boardId: "test-board-id",
|
||||
boardName: "Test Board",
|
||||
listName: "Test List",
|
||||
user: {
|
||||
id: userId,
|
||||
name: ctx.user?.name ?? "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
webhook.url,
|
||||
webhook.secret ?? undefined,
|
||||
testPayload,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -4,11 +4,10 @@ import { z } from "zod";
|
||||
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -84,8 +83,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const isAdmin = userMember?.role === "admin";
|
||||
|
||||
// Show emails if user is admin OR workspace setting allows it
|
||||
const shouldShowEmails =
|
||||
isAdmin || result.showEmailsToMembers === true;
|
||||
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
|
||||
|
||||
// Generate presigned URLs for member avatars
|
||||
const membersWithAvatarUrls = await Promise.all(
|
||||
@@ -295,6 +293,9 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
description: z.string().min(3).max(280).optional(),
|
||||
showEmailsToMembers: z.boolean().optional(),
|
||||
weekStartDay: z
|
||||
.union([z.literal(0), z.literal(1), z.literal(6)])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
@@ -356,6 +357,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
showEmailsToMembers: input.showEmailsToMembers,
|
||||
weekStartDay: input.weekStartDay,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -404,11 +404,10 @@ describe("webhook utilities", () => {
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
@@ -421,8 +420,8 @@ describe("webhook utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
it("does not send when no webhooks match the event (client-side filtering)", async () => {
|
||||
// Returns webhooks that don't match the event — client-side filter excludes them
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
@@ -430,7 +429,6 @@ describe("webhook utilities", () => {
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -185,12 +185,13 @@ export async function sendWebhooksForWorkspace(
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get active webhooks for this workspace
|
||||
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
|
||||
|
||||
// Filter webhooks that are subscribed to this specific event
|
||||
const webhooksForEvent = webhooks.filter((webhook) =>
|
||||
webhook.events.includes(payload.event),
|
||||
// Get active webhooks for this workspace and filter by event client-side
|
||||
const allWebhooks = await webhookRepo.getActiveByWorkspaceId(
|
||||
db,
|
||||
workspaceId,
|
||||
);
|
||||
const webhooksForEvent = allWebhooks.filter((w) =>
|
||||
w.events.includes(payload.event),
|
||||
);
|
||||
|
||||
// Send to all subscribed webhooks in parallel (fire and forget)
|
||||
|
||||
13
packages/api/vitest.config.ts
Normal file
13
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@kan/db": resolve(__dirname, "../db/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal file
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Migrations and snapshots seem to have become out of sync (this should fix that)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = 'source'
|
||||
AND e.enumlabel = 'github'
|
||||
) THEN
|
||||
ALTER TYPE "public"."source" ADD VALUE 'github';
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"workspaceId" bigint NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"secret" text,
|
||||
"events" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"createdBy" uuid NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" ADD COLUMN IF NOT EXISTS "attachmentId" bigint;--> statement-breakpoint
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "weekStartDay" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dc863226-583e-4985-8b6a-4c360a3b9fa1",
|
||||
"prevId": "4e1ebb8b-bd52-48c5-87d6-8e6e5eb8bbde",
|
||||
"prevId": "d0ce9209-d34a-4ae8-b93e-d73391bfec64",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -206,11 +206,18 @@
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1771930355536,
|
||||
"tag": "20260224105235_AddGitHubIntegrationSupport",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1773212242728,
|
||||
"tag": "20260311065722_AddWeekStartDay",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ export const update = async (
|
||||
plan?: "free" | "pro" | "enterprise";
|
||||
description?: string;
|
||||
showEmailsToMembers?: boolean;
|
||||
weekStartDay?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -137,6 +138,7 @@ export const update = async (
|
||||
plan: workspaceInput.plan,
|
||||
description: workspaceInput.description,
|
||||
showEmailsToMembers: workspaceInput.showEmailsToMembers,
|
||||
weekStartDay: workspaceInput.weekStartDay,
|
||||
})
|
||||
.where(eq(workspaces.publicId, workspacePublicId))
|
||||
.returning({
|
||||
@@ -147,6 +149,7 @@ export const update = async (
|
||||
description: workspaces.description,
|
||||
plan: workspaces.plan,
|
||||
showEmailsToMembers: workspaces.showEmailsToMembers,
|
||||
weekStartDay: workspaces.weekStartDay,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -189,6 +192,7 @@ export const getByPublicIdWithMembers = (
|
||||
name: true,
|
||||
slug: true,
|
||||
showEmailsToMembers: true,
|
||||
weekStartDay: true,
|
||||
},
|
||||
with: {
|
||||
members: {
|
||||
@@ -274,6 +278,7 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
||||
description: true,
|
||||
slug: true,
|
||||
plan: true,
|
||||
weekStartDay: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/2903
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
@@ -45,6 +46,7 @@ export const workspaces = pgTable("workspace", {
|
||||
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",
|
||||
}),
|
||||
|
||||
89
pnpm-lock.yaml
generated
89
pnpm-lock.yaml
generated
@@ -351,6 +351,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.2
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
packages/auth:
|
||||
dependencies:
|
||||
@@ -12681,6 +12684,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
@@ -17944,6 +17955,27 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
@@ -17959,6 +17991,21 @@ snapshots:
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.56.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.4.2
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -18001,6 +18048,48 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@25.0.0)(jiti@2.4.2)(terser@5.44.1)(yaml@2.8.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 25.0.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
watchpack@2.4.4:
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"S3_SECRET_ACCESS_KEY",
|
||||
"S3_ENDPOINT",
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
"S3_AVATAR_UPLOAD_LIMIT",
|
||||
"PORT",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
|
||||
Reference in New Issue
Block a user