Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
be106edfea fix: swap latest to only publish on version tags and add edge for builds 2026-03-03 21:43:26 +00:00
106 changed files with 2632 additions and 12844 deletions

View File

@@ -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 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | 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,14 +28,12 @@ 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=
@@ -51,9 +49,6 @@ TRELLO_APP_SECRET=
# If not provided, rate limiting will use in-memory storage
REDIS_URL= # e.g. redis://default:your_password@your_host:6379
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
LOG_LEVEL=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)

2
.gitignore vendored
View File

@@ -54,5 +54,3 @@ i18n.cache
# pgdata
/apps/web/pgdata
.claude

View File

@@ -185,17 +185,9 @@ Use `assertUserInWorkspace` helper for workspace checks.
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
- Provide user-friendly error messages
- Log errors appropriately using the `@kan/logger` package
- Log errors appropriately
- Show popup notifications for user-facing errors
### Logging
- Import from `@kan/logger`: `import { createLogger } from "@kan/logger"`
- Create a module-scoped logger: `const logger = createLogger("module-name")`
- Log level is controlled by `LOG_LEVEL` env var (debug, info, warn, error)
- Defaults to `debug` in development, `info` in production
- Never use `console.log` — always use the logger
## Common Patterns
### Creating a Card
@@ -227,16 +219,6 @@ Use `assertUserInWorkspace` helper for workspace checks.
5. **Frontend**: Add UI components in `apps/web/src/`
6. **i18n**: Add translations for new strings
## Adding a New Environment Variable
Update all of the following:
1. `.env.example` — add the variable with an empty value and a comment explaining it
2. `turbo.json` — add to `globalEnv` (or `globalPassThroughEnv` for CI/platform vars)
3. `docker-compose.yml` — add to the `web` service `environment` section
4. `cloud/docker-compose.yml` — add to the `web` service `environment` section
5. `README.md` — add a row to the Environment Variables table
## Database Changes
- Always create migrations (never modify existing migrations)

View File

@@ -203,7 +203,6 @@ 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` |
@@ -213,7 +212,6 @@ pnpm dev
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| `LOG_LEVEL` | Log verbosity level (debug, info, warn, error) | No (defaults to debug in dev, info in prod) | `info` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -11,10 +11,10 @@ https://kan.bn/api/v1
## Authentication
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key as a Bearer token in the `Authorization` header of each request.
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
```
'Authorization': 'Bearer kan_123456789'
'x-api-key': kan_123456789
```
## Response codes

View File

@@ -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,9 +70,6 @@ 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
@@ -90,8 +87,6 @@ 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
@@ -133,9 +128,6 @@ 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
@@ -223,12 +215,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
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
Dz63YI/message: 8e43abcd6e039b51084937e4829f58ad
Dz63YI/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
WmPhYW/message: 0aa3973b860c1faf8d9123aebf567e40
WmPhYW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
WmPhYW/translation: 0aa3973b860c1faf8d9123aebf567e40
@@ -276,10 +268,6 @@ 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
@@ -526,9 +514,8 @@ checksums:
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/origin/12/0: 83911e3eacbad4583e2b1647a784d154
dEgA5A/origin/13/0: dbad0c8d7863cb41f5495264f82e7081
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
@@ -550,9 +537,6 @@ checksums:
VhMDMg/origin/2/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/origin/3/0: 0627e0040ca4939c9a57349e98c51797
VhMDMg/translation: a552fc5c4189ebc3e2e6018edda7d18f
fzGyWs/translation: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/message: 25dbbca7a83f6af119179e7a56c953d4
fzGyWs/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
yD3ZSw/message: 293d49fc3c75e9c425b64bd7126e6b46
yD3ZSw/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
yD3ZSw/translation: 293d49fc3c75e9c425b64bd7126e6b46
@@ -635,9 +619,6 @@ 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
@@ -647,21 +628,21 @@ checksums:
479pdJ/message: a0d2935d7b63f8dd19d7c0de47524416
479pdJ/origin/0/0: 0627e0040ca4939c9a57349e98c51797
479pdJ/translation: a0d2935d7b63f8dd19d7c0de47524416
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
iSLIjg/message: 8778ee245078a8be4a2ce855c8c56edc
iSLIjg/origin/0/0: 3c57a918258af4b44c187f58d203345f
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
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
@@ -748,16 +729,10 @@ 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
@@ -826,8 +801,6 @@ 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
@@ -849,9 +822,6 @@ 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
@@ -874,9 +844,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
@@ -948,7 +918,6 @@ 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
@@ -964,9 +933,6 @@ 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
@@ -1000,9 +966,6 @@ 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
@@ -1033,9 +996,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
@@ -1051,9 +1014,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
@@ -1084,10 +1047,6 @@ 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
@@ -1110,12 +1069,6 @@ 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
@@ -1123,13 +1076,6 @@ 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
@@ -1170,9 +1116,6 @@ checksums:
l31EoQ/message: b4c05c9174637f4da2a41ead0d65bc01
l31EoQ/origin/0/0: 4980d8e27a628e7cd9a70b839d844083
l31EoQ/translation: b4c05c9174637f4da2a41ead0d65bc01
q3il6U/translation: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/message: 79a4fffd70bfbedb6174ea4c767f4ab9
q3il6U/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
"%2B3TYXa/message": 9fa1c942feb40d6350322b71561df789
"%2B3TYXa/origin/0/0": 4980d8e27a628e7cd9a70b839d844083
"%2B3TYXa/translation": 9fa1c942feb40d6350322b71561df789
@@ -1241,12 +1184,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
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
"%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
@@ -1256,11 +1199,7 @@ 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
@@ -1287,9 +1226,6 @@ 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
@@ -1357,9 +1293,6 @@ 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
@@ -1439,9 +1372,6 @@ checksums:
vXIe7J/message: 277fd1a41cc237a437cd1d5e4a80463b
vXIe7J/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
vXIe7J/translation: 277fd1a41cc237a437cd1d5e4a80463b
k7rCa%2F/translation: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/message: 890b7851af05f97ac53644e4154c13b4
k7rCa%2F/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
8Is1ih/message: 624592ba2ecf647a3fe60d493c1ee71a
8Is1ih/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
8Is1ih/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
@@ -1523,9 +1453,6 @@ checksums:
jl3aEJ/placeholders/0/0: 06f23520ebabfffed4999b5bcf61c9cd
jl3aEJ/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
jl3aEJ/translation: 4c38799ff25321ea25017bf4cd8e2e4f
agPptk/translation: 43983f2178fb28aacaef6c5f41282c4a
agPptk/message: 43983f2178fb28aacaef6c5f41282c4a
agPptk/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
W%2FElkg/message: 1f527cd6d1ed602930bcaa303f503b51
W%2FElkg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
W%2FElkg/translation: 1f527cd6d1ed602930bcaa303f503b51
@@ -1549,9 +1476,6 @@ checksums:
"%2FbZzdR/message": 40ea106c56d79634e058e087ad322025
"%2FbZzdR/origin/0/0": c0b59968b2b234167c488a05cc38710b
"%2FbZzdR/translation": 40ea106c56d79634e058e087ad322025
hty0d5/message: b4b1c8396e445e8b7956c747da2cf601
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
@@ -1567,15 +1491,10 @@ 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
@@ -1622,9 +1541,6 @@ 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
@@ -1665,9 +1581,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
@@ -1675,9 +1591,6 @@ 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
@@ -1727,9 +1640,6 @@ 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
@@ -1808,9 +1718,6 @@ 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
@@ -2025,17 +1932,11 @@ checksums:
YEOVrT/message: 86b76024524fc585b2c3950126ef6f62
YEOVrT/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
YEOVrT/translation: 86b76024524fc585b2c3950126ef6f62
"%2B5kO8P/message": 77b65adf8ae2b69eaed51b6104ded65e
"%2B5kO8P/origin/0/0": 04c86ea96f180745739d59e822da4026
"%2B5kO8P/translation": 77b65adf8ae2b69eaed51b6104ded65e
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
@@ -2045,12 +1946,6 @@ 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
@@ -2061,9 +1956,6 @@ 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
@@ -2079,9 +1971,6 @@ 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
@@ -2110,9 +1999,6 @@ 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
@@ -2151,9 +2037,6 @@ checksums:
zEcnBA/message: 513c3b4d9f0fda60fe079d42dcbd3882
zEcnBA/origin/0/0: c0b59968b2b234167c488a05cc38710b
zEcnBA/translation: 513c3b4d9f0fda60fe079d42dcbd3882
4Revpc/translation: 3adb3ae7891c5736776df840cff2360b
4Revpc/message: 3adb3ae7891c5736776df840cff2360b
4Revpc/origin/0/0: 3de48fb04f7df9bdbe399911cdb3fe86
"%2B%2BrVAm/message": 2227508afb0d38a027e323207e47d4a2
"%2B%2BrVAm/origin/0/0": eb63312c63f6c8d2a5b6520c89012123
"%2B%2BrVAm/translation": 2227508afb0d38a027e323207e47d4a2
@@ -2173,9 +2056,6 @@ 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
@@ -2183,9 +2063,6 @@ checksums:
zzDlyQ/origin/0/0: d96af8a06c2e38a72238bcd16e426859
zzDlyQ/origin/1/0: d96af8a06c2e38a72238bcd16e426859
zzDlyQ/translation: c43827becada6750f7a25890905f38b9
DBC3t5/message: d21934fb36dd8a82e36352968207d44e
DBC3t5/origin/0/0: 04c86ea96f180745739d59e822da4026
DBC3t5/translation: d21934fb36dd8a82e36352968207d44e
uZg2%2Bw/message: 21af8119141afcce5f12da489f34db6a
uZg2%2Bw/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
uZg2%2Bw/translation: 21af8119141afcce5f12da489f34db6a
@@ -2231,13 +2108,6 @@ 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
@@ -2266,9 +2136,6 @@ 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
@@ -2329,9 +2196,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
@@ -2551,17 +2418,10 @@ 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
@@ -2573,9 +2433,6 @@ 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
@@ -2615,35 +2472,6 @@ 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/message: ce928c74255344905f5c3f02f44c991e
xLTZVf/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
xLTZVf/translation: ce928c74255344905f5c3f02f44c991e
9eF5oV/message: 4928884739ba559e6e4b960e80fe1452
9eF5oV/origin/0/0: 4a1be3707d14c696a16f453d132fb248
9eF5oV/translation: 4928884739ba559e6e4b960e80fe1452
@@ -2787,24 +2615,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
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
AdxYds/message: a265e2d3b01efd3fe04ac251d2ff6afc
AdxYds/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
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

View File

@@ -27,7 +27,6 @@
"@kan/api": "workspace:*",
"@kan/auth": "workspace:*",
"@kan/db": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@lingui/babel-preset-react": "^2.9.2",
"@lingui/conf": "^5.3.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -145,6 +145,7 @@ export default function CommandPallette({
value={result}
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
onClick={() => {
console.log("clicked", url);
void router.push(url);
onClose();
setQuery("");

View File

@@ -17,14 +17,9 @@ import { twMerge } from "tailwind-merge";
interface DateSelectorProps {
selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void;
weekStartsOn?: 0 | 1 | 6;
}
const DateSelector = ({
selectedDate,
onDateSelect,
weekStartsOn = 1,
}: DateSelectorProps) => {
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
});
@@ -33,18 +28,18 @@ const DateSelector = ({
const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn });
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
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 });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => {
@@ -58,7 +53,7 @@ const DateSelector = ({
};
},
);
}, [currentMonth, selectedDate, weekStartsOn]);
}, [currentMonth, selectedDate]);
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));

View File

@@ -1,32 +0,0 @@
import { t } from "@lingui/core/macro";
import { HiOutlineAdjustmentsHorizontal } from "react-icons/hi2";
import { useFontSize, type FontSize } from "~/providers/font-size";
const fontSizeOptions: { value: FontSize; label: () => string }[] = [
{ value: "small", label: () => t`Small` },
{ value: "medium", label: () => t`Medium` },
{ value: "large", label: () => t`Large` },
];
export function FontSizeSelector() {
const { fontSize, setFontSize } = useFontSize();
return (
<div className="relative">
<HiOutlineAdjustmentsHorizontal className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<select
id="font-size-select"
value={fontSize}
onChange={(e) => setFontSize(e.target.value as FontSize)}
className="block w-full max-w-[180px] rounded-lg border-0 bg-light-50 pl-10 text-sm shadow-sm ring-1 ring-inset ring-light-300 focus:ring-2 focus:ring-inset focus:ring-light-400 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500"
>
{fontSizeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label()}
</option>
))}
</select>
</div>
);
}

View File

@@ -75,7 +75,7 @@ export function LabelForm({
reset(newFormState);
}
} catch (e) {
console.error(e);
console.log(e);
}
},
});
@@ -231,7 +231,9 @@ export function LabelForm({
<Button
type="submit"
isLoading={updateLabel.isPending || createLabel.isPending}
disabled={!watch("name")}
disabled={
!watch("name")
}
>
{isEdit ? t`Update label` : t`Create label`}
</Button>

View File

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

View File

@@ -8,7 +8,6 @@ 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>
);
};

View File

@@ -1,130 +0,0 @@
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useRef } from "react";
import { twMerge } from "tailwind-merge";
interface PlainTextEditorProps {
content: string;
onChange?: (value: string) => void;
onBlur?: (value: string) => void;
onEnter?: (value: string) => void;
onEscape?: () => void;
readOnly?: boolean;
placeholder?: string;
className?: string;
}
export default function PlainTextEditor({
content,
onChange,
onBlur,
onEnter,
onEscape,
readOnly = false,
placeholder,
className,
}: PlainTextEditorProps) {
const onEnterRef = useRef(onEnter);
const onEscapeRef = useRef(onEscape);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
const contentRef = useRef(content);
useEffect(() => {
onEnterRef.current = onEnter;
}, [onEnter]);
useEffect(() => {
onEscapeRef.current = onEscape;
}, [onEscape]);
useEffect(() => {
onBlurRef.current = onBlur;
}, [onBlur]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
contentRef.current = content;
}, [content]);
const editor = useEditor(
{
extensions: [
StarterKit.configure({
bold: false,
italic: false,
strike: false,
code: false,
codeBlock: false,
blockquote: false,
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
horizontalRule: false,
hardBreak: false,
}),
Placeholder.configure({ placeholder }),
],
content,
editable: !readOnly,
onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === "Enter") {
event.preventDefault();
onEnterRef.current?.(view.state.doc.textContent.trim());
return true;
}
if (event.key === "Escape") {
event.preventDefault();
// Reset to original content before calling the callback
editor?.commands.setContent(contentRef.current, false);
editor?.commands.blur();
onEscapeRef.current?.();
return true;
}
return false;
},
attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0",
},
},
},
[],
);
useEffect(() => {
if (!editor) return;
if (content !== editor.getText()) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
useEffect(() => {
if (!editor) return;
editor.setEditable(!readOnly);
}, [readOnly, editor]);
return (
<>
<style jsx global>{`
.plain-text-editor p.is-empty::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--placeholder-color, #9ca3af);
}
.plain-text-editor .tiptap p {
margin: 0 !important;
}
`}</style>
<EditorContent
editor={editor}
className={twMerge("plain-text-editor", className)}
/>
</>
);
}

View File

@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import {
HiChevronDown,
HiOutlineBanknotes,
HiOutlineBolt,
HiOutlineCodeBracketSquare,
HiOutlineRectangleGroup,
HiOutlineShieldCheck,
@@ -65,12 +64,6 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
label: t`API`,
condition: true,
},
{
key: "webhooks",
icon: <HiOutlineBolt />,
label: t`Webhooks`,
condition: isAdmin,
},
{
key: "integrations",
icon: <HiOutlineCodeBracketSquare />,

View File

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

View File

@@ -1,55 +0,0 @@
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>
</>
);
}

View File

@@ -12,7 +12,6 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { FontSizeProvider } from "~/providers/font-size";
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
@@ -84,21 +83,19 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
<main className="font-sans">
<KeyboardShortcutProvider>
<LinguiProviderWrapper>
<FontSizeProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</FontSizeProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</KeyboardShortcutProvider>
</main>

View File

@@ -2,74 +2,48 @@ 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" });
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",
});
}
const { url, filename } = req.query;
const contentType =
upstream.headers.get("Content-Type") ?? "application/octet-stream";
if (!url || typeof url !== "string") {
return res.status(400).json({ message: "url parameter is required" });
}
res.setHeader("Content-Type", contentType);
res.setHeader(
"Content-Disposition",
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
);
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" });
}
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" });
}
},
);

View File

@@ -3,11 +3,8 @@ import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe";
const log = createLogger("stripe-webhook");
async function buffer(readable: Readable) {
const chunks = [];
for await (const chunk of readable) {
@@ -44,8 +41,6 @@ export default async function handler(
const { db } = await createNextApiContext(req);
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
switch (event.type) {
case "checkout.session.completed": {
const checkoutSession = event.data.object;
@@ -61,12 +56,12 @@ export default async function handler(
break;
}
default:
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
console.log(`Unhandled event type: ${event.type}`);
}
return res.status(200).json({ received: true });
} catch (err) {
log.error({ err }, "Stripe webhook handler failed");
console.error("Webhook error:", err);
return res.status(400).json({ message: "Webhook handler failed" });
}
}

View File

@@ -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 = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
export const config = {

View File

@@ -1,25 +1,24 @@
import { useEffect } from "react";
export default function TrelloAuthorize() {
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
useEffect(() => {
const hash = window.location.hash;
const token = hash.split("=")[1];
if (token) {
console.log("Posting token to /api/trello/authenticate", token);
fetch("/api/trello/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token }),
}).then(() => {
window.close();
});
}
}, []);
return (
<div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>
);
}
return <div className="flex h-[200px] items-center justify-center">
<p className="text-center">Connecting to Trello...</p>
</div>;
}

View File

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

View File

@@ -1,57 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type FontSize = "small" | "medium" | "large";
const fontSizeMap: Record<FontSize, string> = {
small: "14px",
medium: "16px",
large: "18px",
};
interface FontSizeContextProps {
fontSize: FontSize;
setFontSize: (size: FontSize) => void;
}
const FontSizeContext = createContext<FontSizeContextProps | undefined>(
undefined,
);
export const FontSizeProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [fontSize, setFontSizeState] = useState<FontSize>("medium");
const setFontSize = (size: FontSize) => {
document.documentElement.style.fontSize = fontSizeMap[size];
localStorage.setItem("fontSize", size);
setFontSizeState(size);
};
useEffect(() => {
const stored = localStorage.getItem("fontSize") as FontSize | null;
const size = stored && fontSizeMap[stored] ? stored : "medium";
document.documentElement.style.fontSize = fontSizeMap[size];
setFontSizeState(size);
}, []);
return (
<FontSizeContext.Provider value={{ fontSize, setFontSize }}>
{children}
</FontSizeContext.Provider>
);
};
export const useFontSize = (): FontSizeContextProps => {
const context = useContext(FontSizeContext);
if (!context) {
throw new Error("useFontSize must be used within a FontSizeProvider");
}
return context;
};

View File

@@ -19,7 +19,6 @@ interface Workspace {
slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6;
}
const initialWorkspace: Workspace = {
@@ -29,14 +28,13 @@ 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,
@@ -81,7 +79,6 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: workspace.slug,
description: workspace.description,
plan: workspace.plan,
weekStartDay: workspace.weekStartDay,
hasLoaded: true,
})) as Workspace[];
@@ -103,7 +100,6 @@ 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) {
@@ -123,7 +119,6 @@ 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]);

View File

@@ -2,10 +2,6 @@
@tailwind components;
@tailwind utilities;
html {
font-size: 14px;
}
[contenteditable="true"]:empty:before {
content: attr(placeholder);
display: block;

View File

@@ -24,7 +24,6 @@ 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";
@@ -54,7 +53,6 @@ export function NewCardForm({
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { closeModal, openModal, modalStates, clearModalState } = useModal();
const utils = api.useUtils();
@@ -493,7 +491,6 @@ export function NewCardForm({
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -26,7 +26,6 @@ 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";
@@ -125,21 +124,11 @@ 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 });
};
@@ -152,8 +141,6 @@ 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();

View File

@@ -234,7 +234,7 @@ const ImportGithub: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.error(e);
console.log(e);
}
},
onError: () => {
@@ -386,7 +386,7 @@ const ImportTrello: React.FC = () => {
await refetchBoards();
closeModal();
} catch (e) {
console.error(e);
console.log(e);
}
},
onError: () => {

View File

@@ -1,11 +1,11 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useState } from "react";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";
import PlainTextEditor from "~/components/PlainTextEditor";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
@@ -33,7 +33,9 @@ export default function ChecklistItemRow({
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [completed, setCompleted] = useState(item.completed);
const [title, setTitle] = useState("");
const [completed, setCompleted] = useState(false);
const updateItem = api.checklist.updateItem.useMutation({
onMutate: async (vars) => {
@@ -101,6 +103,21 @@ export default function ChecklistItemRow({
},
});
// Only resync from props when switching items to avoid clobbering edits
useEffect(() => {
setTitle(item.title);
setCompleted(item.completed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev);
@@ -110,8 +127,14 @@ export default function ChecklistItemRow({
});
};
const commitTitle = (plain: string) => {
if (!plain || plain === item.title) return;
const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
return;
}
setTitle(plain);
updateItem.mutate({
checklistItemPublicId: item.publicId,
title: plain,
@@ -160,26 +183,36 @@ export default function ChecklistItemRow({
)}
/>
</label>
<div className="flex-1 pr-7">
<PlainTextEditor
key={item.publicId}
content={item.title}
readOnly={viewOnly}
placeholder={t`Add details...`}
onBlur={commitTitle}
onEnter={(plain) => {
commitTitle(plain);
onCreateNewItem?.();
<ContentEditable
html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => {
const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(innerHTML);
}}
onEscape={() => undefined}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 dark:text-dark-950",
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default",
)}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/>
</div>
{!viewOnly && (
<button
type="button"

View File

@@ -5,7 +5,6 @@ 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";
@@ -23,7 +22,6 @@ 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>(
@@ -137,7 +135,6 @@ export function DueDateSelector({
<DateSelector
selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect}
weekStartsOn={workspace.weekStartDay}
/>
</div>
</>

View File

@@ -3,8 +3,8 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5";
import { HiXMark } from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
@@ -185,20 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const { data: card, isLoading } = 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;
@@ -311,6 +302,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
if (!cardId) return <></>;
return (
<>
<PageHead
@@ -353,7 +345,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
aria-label={t`Close`}
>
<HiXMark className="h-4 w-4" />
<HiXMark className="h-5 w-5" />
</Link>
</div>
</>

View File

@@ -8,10 +8,10 @@ type FrequencyValue = "monthly" | "annually";
interface PlanFeature {
key: string;
label: string;
free: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
teams: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
pro: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
enterprise: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
free: string | boolean | { text: string; highlight?: string };
teams: string | boolean | { text: string; highlight?: string };
pro: string | boolean | { text: string; highlight?: string };
enterprise: string | boolean | { text: string; highlight?: string };
}
interface FeatureSection {
@@ -71,34 +71,34 @@ const FeatureComparisonTable = ({
{
key: "boards",
label: t`Boards`,
free: { text: t`Unlimited boards`, highlightFirstWord: true },
teams: { text: t`Unlimited boards`, highlightFirstWord: true },
pro: { text: t`Unlimited boards`, highlightFirstWord: true },
enterprise: { text: t`Unlimited boards`, highlightFirstWord: true },
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
},
{
key: "members",
label: t`Members`,
free: { text: t`1 user`, highlightFirstWord: true },
teams: { text: t`Per-seat pricing`, highlightFirstWord: true },
pro: { text: t`Unlimited members`, highlightFirstWord: true },
enterprise: { text: t`Unlimited members`, highlightFirstWord: true },
free: { text: t`1 user`, highlight: "1" },
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
},
{
key: "file-uploads",
label: t`File uploads`,
free: { text: t`10mb file uploads`, highlightFirstWord: true },
teams: { text: t`Unlimited file uploads`, highlightFirstWord: true },
pro: { text: t`Unlimited file uploads`, highlightFirstWord: true },
enterprise: { text: t`Unlimited file uploads`, highlightFirstWord: true },
free: { text: t`10mb file uploads`, highlight: "10mb" },
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
},
{
key: "workspace-username",
label: t`Workspace username`,
free: { text: t`Default username`, highlightFirstWord: true },
teams: { text: t`Default username`, highlightFirstWord: true },
pro: { text: t`Custom username`, highlightFirstWord: true },
enterprise: { text: t`Custom username`, highlightFirstWord: true },
free: { text: t`Default username`, highlight: "Default" },
teams: { text: t`Default username`, highlight: "Default" },
pro: { text: t`Custom username`, highlight: "Custom" },
enterprise: { text: t`Custom username`, highlight: "Custom" },
},
];
@@ -236,9 +236,9 @@ const FeatureComparisonTable = ({
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access`, highlight: true },
pro: { text: t`Full API access`, highlight: true },
enterprise: { text: t`Full API access`, highlight: true },
teams: { text: t`Full API access` },
pro: { text: t`Full API access` },
enterprise: { text: t`Full API access` },
},
{
key: "self-hostable",
@@ -254,18 +254,18 @@ const FeatureComparisonTable = ({
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO`, highlight: true },
teams: { text: t`Google SSO`, highlight: true },
pro: { text: t`Google SSO`, highlight: true },
enterprise: { text: t`Google SSO + SAML`, highlight: true },
free: { text: t`Google SSO` },
teams: { text: t`Google SSO` },
pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML` },
},
{
key: "admin-roles",
label: t`Admin roles`,
free: { text: t`Admin roles`, highlight: true },
teams: { text: t`Admin roles`, highlight: true },
pro: { text: t`Admin roles`, highlight: true },
enterprise: { text: t`Advanced admin roles`, highlight: true },
free: { text: t`Admin roles` },
teams: { text: t`Admin roles` },
pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced admin roles` },
},
];
@@ -334,37 +334,59 @@ const FeatureComparisonTable = ({
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean };
value: string | boolean | { text: string; highlight?: string };
label: string;
}) => {
if (typeof value === "object" && value !== null) {
const { text, highlight, highlightFirstWord } = value;
if (highlightFirstWord) {
const spaceIndex = text.indexOf(" ");
const firstWord = spaceIndex === -1 ? text : text.slice(0, spaceIndex);
const rest = spaceIndex === -1 ? "" : text.slice(spaceIndex);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium">
<span className="text-light-1000 dark:text-dark-1000">{firstWord}</span>
<span className="text-light-950 dark:text-dark-800">{rest}</span>
</span>
</div>
// Handle object with text and highlight
if (isHighlightValue(value)) {
const { text, highlight } = value;
const isUnlimited = shouldHighlight(text);
let displayText;
if (highlight) {
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
displayText = (
<>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span key={index} className="text-light-1000 dark:text-dark-1000">
{part}
</span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
);
} else {
displayText = text;
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
highlight
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
@@ -372,18 +394,44 @@ const FeatureComparisonTable = ({
<span
className={twMerge(
"text-sm font-medium",
highlight
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{text}
{displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
</span>
</div>
);
}
if (value) {
// Boolean true - show checkmark icon
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
@@ -393,7 +441,7 @@ const FeatureComparisonTable = ({
</div>
);
}
// Boolean false - show cross icon
return (
<div className="flex items-center gap-2.5">
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
@@ -407,7 +455,7 @@ const FeatureComparisonTable = ({
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: boolean; highlightFirstWord?: boolean } => {
): string | boolean | { text: string; highlight?: string } => {
switch (planId) {
case "free":
return feature.free;

View File

@@ -47,7 +47,7 @@ export function CardModal({
}
};
const { data, isLoading, error } = api.card.byId.useQuery(
const { data, isLoading } = api.card.byId.useQuery(
{
cardPublicId: cardPublicId ?? "",
},
@@ -56,17 +56,6 @@ 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 = () => {

View File

@@ -1,6 +1,5 @@
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";
@@ -13,22 +12,13 @@ export default function PublicBoardsView() {
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
const { data, isLoading } = 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,

View File

@@ -3,7 +3,6 @@ import { env } from "next-runtime-env";
import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal";
import { FontSizeSelector } from "~/components/FontSizeSelector";
import { LanguageSelector } from "~/components/LanguageSelector";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
@@ -55,16 +54,6 @@ export default function AccountSettings() {
<LanguageSelector />
</div>
<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`Font size`}
</h2>
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
{t`Change the application font size.`}
</p>
<FontSizeSelector />
</div>
<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`Delete account`}

View File

@@ -1,78 +0,0 @@
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>
</>
);
}

View File

@@ -21,7 +21,6 @@ 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() {
@@ -87,15 +86,6 @@ 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,9 +37,6 @@ services:
- NEXT_PUBLIC_USE_STANDALONE_OUTPUT=${NEXT_PUBLIC_USE_STANDALONE_OUTPUT}
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Stripe
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
@@ -69,7 +66,6 @@ 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}

View File

@@ -37,9 +37,6 @@ services:
# Redis (optional - for rate limiting)
- REDIS_URL=${REDIS_URL}
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
- LOG_LEVEL=${LOG_LEVEL}
# Admin API key (optional)
- KAN_ADMIN_API_KEY=${KAN_ADMIN_API_KEY}
@@ -60,7 +57,6 @@ 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}

View File

@@ -1,75 +0,0 @@
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! };
}

View File

@@ -1,242 +0,0 @@
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();
});
});
});

View File

@@ -40,15 +40,12 @@
"dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@kan/auth": "workspace:*",
"@kan/db": "workspace:*",
"@kan/email": "workspace:^",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"@kan/stripe": "workspace:^",
"@trpc/server": "catalog:",
@@ -63,8 +60,7 @@
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "^3.0.0"
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

View File

@@ -11,7 +11,6 @@ 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";
@@ -28,7 +27,6 @@ export const appRouter = createTRPCRouter({
import: importRouter,
permission: permissionRouter,
user: userRouter,
webhook: webhookRouter,
workspace: workspaceRouter,
integration: integrationRouter,
});

View File

@@ -4,7 +4,6 @@ import { z } from "zod";
import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";
import { stripHtml } from "@kan/shared/utils";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertPermission } from "../utils/permissions";
@@ -224,7 +223,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistPublicId: z.string().length(12),
title: z.string().min(1).max(500).transform(stripHtml),
title: z.string().min(1).max(500),
}),
)
.output(checklistItemSchema)
@@ -289,7 +288,7 @@ export const checklistRouter = createTRPCRouter({
.input(
z.object({
checklistItemPublicId: z.string().length(12),
title: z.string().min(1).max(500).transform(stripHtml).optional(),
title: z.string().min(1).max(500).optional(),
completed: z.boolean().optional(),
index: z.number().int().min(0).optional(),
}),
@@ -323,29 +322,30 @@ export const checklistRouter = createTRPCRouter({
const previousTitle = item.title;
let updatedItem;
let updatedItem;
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.title !== undefined || input.completed !== undefined) {
updatedItem = await checklistRepo.updateItemById(ctx.db, {
id: item.id,
title: input.title,
completed: input.completed,
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (input.index !== undefined) {
updatedItem = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
if (!updatedItem) {
throw new TRPCError({
message: `Failed to update checklist item`,
code: "INTERNAL_SERVER_ERROR",
});
}
// Log completion toggle
if (input.completed !== undefined) {
@@ -371,6 +371,7 @@ export const checklistRouter = createTRPCRouter({
}
return updatedItem;
}),
deleteItem: protectedProcedure
.meta({

View File

@@ -19,7 +19,6 @@ import { encryptToken } from "../utils/encryption";
export const integrationRouter = createTRPCRouter({
saveGitHubToken: protectedProcedure
.input(z.object({ token: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
@@ -44,9 +43,7 @@ export const integrationRouter = createTRPCRouter({
return { success: true };
}),
disconnectGitHub: protectedProcedure
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx }) => {
disconnectGitHub: protectedProcedure.mutation(async ({ ctx }) => {
const user = ctx.user;
if (!user)
@@ -59,9 +56,7 @@ export const integrationRouter = createTRPCRouter({
return { success: true };
}),
getGitHubStatus: protectedProcedure
.output(z.object({ connected: z.boolean() }))
.query(async ({ ctx }) => {
getGitHubStatus: protectedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
if (!user)

View File

@@ -31,6 +31,7 @@ export const userRouter = createTRPCRouter({
.object({
id: z.number(),
prefix: z.string().nullable(),
key: z.string(),
})
.nullable(),
}),
@@ -61,7 +62,7 @@ export const userRouter = createTRPCRouter({
return {
...result,
image: imageUrl,
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
apiKey: apiKey ?? null,
};
}),
update: protectedProcedure

View File

@@ -1,423 +0,0 @@
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");
});
});
});

View File

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

View File

@@ -4,10 +4,11 @@ import { z } from "zod";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
import { 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
@@ -83,7 +84,8 @@ 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(
@@ -293,9 +295,6 @@ 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>>>())
@@ -357,7 +356,6 @@ export const workspaceRouter = createTRPCRouter({
slug: input.slug,
description: input.description,
showEmailsToMembers: input.showEmailsToMembers,
weekStartDay: input.weekStartDay,
},
);

View File

@@ -9,9 +9,6 @@ import { ZodError } from "zod";
import type { dbClient } from "@kan/db/client";
import { initAuth } from "@kan/auth/server";
import { createDrizzleClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("trpc");
export interface User {
id: string;
@@ -93,7 +90,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
try {
session = await auth.api.getSession();
} catch (error) {
log.error({ err: error }, "Error getting session");
console.error("Error getting session, ", error);
throw error;
}
@@ -121,24 +118,10 @@ export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
const loggingMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
const meta = { procedure: path, type, duration, userId: (ctx as { user?: { id: string } }).user?.id };
if (result.ok) {
log.info(meta, "tRPC OK");
} else {
log.error({ ...meta, err: result.error }, "tRPC error");
}
return result;
export const publicProcedure = t.procedure.meta({
openapi: { method: "GET", path: "/public" },
});
export const publicProcedure = t.procedure.use(loggingMiddleware);
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -159,12 +142,14 @@ const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
});
});
export const protectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAuthed);
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
openapi: {
method: "GET",
path: "/protected",
},
});
export const adminProtectedProcedure = t.procedure
.use(loggingMiddleware)
.use(enforceUserIsAdmin)
.meta({
openapi: {

View File

@@ -1,9 +1,6 @@
import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client";
import { createLogger } from "@kan/logger";
const log = createLogger("notifications");
import * as cardRepo from "@kan/db/repository/card.repo";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as notificationRepo from "@kan/db/repository/notification.repo";
@@ -75,7 +72,6 @@ export async function sendMentionEmails({
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
log.info({ cardPublicId, mentionCount: membersToNotify.length, commenterUserId }, "Sending mention emails");
// Send emails to all mentioned members (only if notification doesn't exist)
await Promise.all(
membersToNotify.map(async (member) => {
@@ -95,7 +91,6 @@ export async function sendMentionEmails({
// If notification already exists, skip sending email
if (notificationExists) {
log.debug({ email, cardPublicId }, "Skipping duplicate mention email");
return;
}
@@ -119,14 +114,20 @@ export async function sendMentionEmails({
cardUrl,
},
);
log.info({ email, cardPublicId }, "Mention email sent");
} catch (error) {
log.error({ err: error, email, cardPublicId }, "Failed to send mention email");
console.error("Failed to send mention email:", {
email,
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
}
}),
);
} catch (error) {
log.error({ err: error, cardPublicId }, "Error sending mention emails");
console.error("Error sending mention emails:", {
cardPublicId,
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -5,9 +5,6 @@ import {
} from "rate-limiter-flexible";
import { getRedisClient } from "@kan/db/redis";
import { createLogger } from "@kan/logger";
const log = createLogger("rateLimit");
export interface RateLimitOptions {
points?: number;
@@ -48,7 +45,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
// Use Redis if available, otherwise fall back to in-memory storage
if (redis) {
log.debug("Using Redis for rate limiting");
console.log("Using Redis for rate limiting");
return new RateLimiterRedis({
storeClient: redis,
points,
@@ -56,7 +53,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
});
}
log.debug("Redis unavailable, falling back to in-memory rate limiting");
console.log("Using in-memory for rate limiting");
return new RateLimiterMemory({
points,
duration,

View File

@@ -404,10 +404,11 @@ describe("webhook utilities", () => {
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
// Event filtering now happens at DB level
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenCalledWith(
@@ -420,8 +421,8 @@ describe("webhook utilities", () => {
);
});
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
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
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
@@ -429,6 +430,7 @@ describe("webhook utilities", () => {
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
mockDb,
1,
"card.created",
);
expect(global.fetch).not.toHaveBeenCalled();
});

View File

@@ -4,9 +4,6 @@ import { z } from "zod";
import type { dbClient } from "@kan/db/client";
import type { WebhookEvent } from "@kan/db/schema";
import * as webhookRepo from "@kan/db/repository/webhook.repo";
import { createLogger } from "@kan/logger";
const log = createLogger("webhook");
export type WebhookEventType = WebhookEvent;
@@ -188,13 +185,12 @@ export async function sendWebhooksForWorkspace(
payload: WebhookPayload,
): Promise<void> {
try {
// 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),
// 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),
);
// Send to all subscribed webhooks in parallel (fire and forget)
@@ -202,9 +198,9 @@ export async function sendWebhooksForWorkspace(
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
(result) => {
if (!result.success) {
log.error({ url: webhook.url, event: payload.event, error: result.error, statusCode: result.statusCode }, "Webhook delivery failed");
} else {
log.info({ url: webhook.url, event: payload.event, statusCode: result.statusCode }, "Webhook delivered");
console.error(
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
);
}
},
),
@@ -213,7 +209,7 @@ export async function sendWebhooksForWorkspace(
// Wait for all to complete but don't block on failures
await Promise.allSettled(promises);
} catch (error) {
log.error({ err: error, workspaceId }, "Failed to send webhooks for workspace");
console.error("Failed to send webhooks for workspace:", error);
}
}

View File

@@ -1,13 +0,0 @@
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"),
},
},
});

View File

@@ -39,7 +39,6 @@
"prettier": "@kan/prettier-config",
"dependencies": {
"@better-auth/stripe": "^1.4.6",
"@kan/logger": "workspace:^",
"better-auth": "^1.4.6"
}
}

View File

@@ -7,11 +7,8 @@ import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createLogger } from "@kan/logger";
import { createEmailUnsubscribeLink, createS3Client } from "@kan/shared";
const log = createLogger("auth");
import { downloadImage } from "./utils";
type BetterAuthUser = {
@@ -106,7 +103,6 @@ export function createDatabaseHooks(db: dbClient) {
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
log.info({ workflowId: "user-signup", userId: user.id, email: user.email }, "Triggering Novu workflow");
await notificationClient.trigger({
to: {
subscriberId: user.id,
@@ -126,7 +122,6 @@ export function createDatabaseHooks(db: dbClient) {
},
workflowId: "user-signup",
});
log.info({ workflowId: "user-signup", userId: user.id }, "Novu workflow triggered");
await notificationClient.subscribers.credentials.update(
{
@@ -139,7 +134,7 @@ export function createDatabaseHooks(db: dbClient) {
user.id,
);
} catch (error) {
log.error({ err: error }, "Error adding user to notification client");
console.error("Error adding user to notification client", error);
}
}
},

View File

@@ -8,10 +8,7 @@ import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { createLogger } from "@kan/logger";
import { generateUID } from "@kan/shared/utils";
const log = createLogger("auth");
import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers";
@@ -104,7 +101,9 @@ export function createPlugins(db: dbClient) {
unlimitedSeats: true,
},
);
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
console.log(
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
);
const workspace = await workspaceRepo.getByPublicId(
db,
@@ -165,13 +164,6 @@ export function createPlugins(db: dbClient) {
: []),
apiKey({
enableSessionForAPIKeys: true,
customAPIKeyGetter: (ctx) => {
const authorization = ctx.headers?.get("authorization");
if (authorization?.startsWith("Bearer ")) {
return authorization.slice(7);
}
return ctx.headers?.get("x-api-key") ?? undefined;
},
rateLimit: {
enabled: true,
timeWindow: 1000 * 60, // 1 minute
@@ -183,7 +175,11 @@ export function createPlugins(db: dbClient) {
sendMagicLink: async ({ email, url }) => {
try {
const decodedUrl = decodeURIComponent(url);
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
console.log("Sending magic link to:", email, "URL:", url);
console.log(
"Magic link contains invite:",
decodedUrl.includes("type=invite"),
);
if (decodedUrl.includes("type=invite")) {
let inviterName = "";
let workspaceName = "";
@@ -215,7 +211,7 @@ export function createPlugins(db: dbClient) {
}
}
} catch (error) {
log.error({ err: error }, "Failed to fetch invite details");
console.error("Failed to fetch invite details:", error);
}
await sendEmail(
@@ -243,7 +239,11 @@ export function createPlugins(db: dbClient) {
);
}
} catch (error) {
log.error({ err: error, email }, "Error sending magic link");
console.error("Error sending magic link:", {
email,
url,
error,
});
}
},
}),
@@ -273,7 +273,7 @@ export function createPlugins(db: dbClient) {
picture?: string;
avatar?: string;
}) => {
log.debug({ profile }, "OIDC profile received");
console.log("OIDC profile:", profile);
const name =
profile.name ??

View File

@@ -4,11 +4,8 @@ import type Stripe from "stripe";
import type { dbClient } from "@kan/db/client";
import * as userRepo from "@kan/db/repository/user.repo";
import { notificationClient } from "@kan/email";
import { createLogger } from "@kan/logger";
import { createEmailUnsubscribeLink } from "@kan/shared";
const log = createLogger("auth");
export async function downloadImage(url: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) {
@@ -35,7 +32,6 @@ export async function triggerWorkflow(
const unsubscribeUrl = await createEmailUnsubscribeLink(user.id);
log.info({ workflowId, userId: user.id }, "Triggering Novu workflow");
await notificationClient.trigger({
to: {
subscriberId: user.id,
@@ -47,8 +43,7 @@ export async function triggerWorkflow(
},
workflowId,
});
log.info({ workflowId, userId: user.id }, "Novu workflow triggered");
} catch (error) {
log.error({ err: error, workflowId }, "Error triggering workflow");
console.error("Error triggering workflow", error);
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"id": "dc863226-583e-4985-8b6a-4c360a3b9fa1",
"prevId": "d0ce9209-d34a-4ae8-b93e-d73391bfec64",
"prevId": "4e1ebb8b-bd52-48c5-87d6-8e6e5eb8bbde",
"version": "7",
"dialect": "postgresql",
"tables": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -206,18 +206,11 @@
"breakpoints": true
},
{
"idx": 29,
"idx": 28,
"version": "7",
"when": 1771930355536,
"tag": "20260224105235_AddGitHubIntegrationSupport",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1773212242728,
"tag": "20260311065722_AddWeekStartDay",
"breakpoints": true
}
]
}
}

View File

@@ -44,7 +44,6 @@
},
"dependencies": {
"@electric-sql/pglite": "^0.3.7",
"@kan/logger": "workspace:^",
"@kan/shared": "workspace:^",
"drizzle-orm": "^0.42.0",
"drizzle-zod": "^0.5.1",

View File

@@ -6,12 +6,8 @@ import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
import { Pool } from "pg";
import { createLogger } from "@kan/logger";
import * as schema from "./schema";
const log = createLogger("db");
export type dbClient = NodePgDatabase<typeof schema> & {
$client: Pool;
};
@@ -20,7 +16,7 @@ export const createDrizzleClient = (): dbClient => {
const connectionString = process.env.POSTGRES_URL;
if (!connectionString) {
log.warn("POSTGRES_URL not set, falling back to PGLite");
console.log("POSTGRES_URL environment variable is not set, using PGLite");
const client = new PGlite({
dataDir: "./pgdata",

View File

@@ -402,6 +402,8 @@ export const softDeleteById = async (
.groupBy(lists.index)
.having(gt(countExpr, 1));
console.log(duplicateIndices);
if (duplicateIndices.length > 0) {
throw new Error(
`Duplicate indices found after reordering in board ${result.boardId}`,

View File

@@ -127,7 +127,6 @@ export const update = async (
plan?: "free" | "pro" | "enterprise";
description?: string;
showEmailsToMembers?: boolean;
weekStartDay?: number;
},
) => {
const [result] = await db
@@ -138,7 +137,6 @@ export const update = async (
plan: workspaceInput.plan,
description: workspaceInput.description,
showEmailsToMembers: workspaceInput.showEmailsToMembers,
weekStartDay: workspaceInput.weekStartDay,
})
.where(eq(workspaces.publicId, workspacePublicId))
.returning({
@@ -149,7 +147,6 @@ export const update = async (
description: workspaces.description,
plan: workspaces.plan,
showEmailsToMembers: workspaces.showEmailsToMembers,
weekStartDay: workspaces.weekStartDay,
});
return result;
@@ -192,7 +189,6 @@ export const getByPublicIdWithMembers = (
name: true,
slug: true,
showEmailsToMembers: true,
weekStartDay: true,
},
with: {
members: {
@@ -278,7 +274,6 @@ 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

View File

@@ -3,7 +3,6 @@ import {
bigint,
bigserial,
boolean,
integer,
pgEnum,
pgTable,
text,
@@ -46,7 +45,6 @@ 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",
}),

View File

@@ -23,7 +23,6 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"@kan/logger": "workspace:^",
"@novu/api": "^3.11.0",
"@react-email/components": "^1.0.1",
"nodemailer": "^7.0.3",

View File

@@ -1,8 +1,5 @@
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import { createLogger } from "@kan/logger";
const log = createLogger("email");
import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link";
@@ -47,7 +44,6 @@ export const sendEmail = async (
template: Templates,
data: Record<string, string>,
) => {
log.info({ to, subject, template }, "Sending email");
try {
const EmailTemplate = emailTemplates[template];
@@ -66,10 +62,16 @@ export const sendEmail = async (
throw new Error(`Failed to send email: ${response.response}`);
}
log.info({ to, subject, template, messageId: response.messageId }, "Email sent");
return response;
} catch (error) {
log.error({ err: error, to, from: process.env.EMAIL_FROM, subject, template }, "Email sending failed");
console.error("Email sending failed:", {
to,
from: process.env.EMAIL_FROM,
subject,
template,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};

View File

@@ -1,34 +0,0 @@
{
"name": "@kan/logger",
"private": true,
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./src/index.ts"
}
},
"license": "GPL-3.0",
"scripts": {
"build": "tsc",
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
},
"dependencies": {
"pino": "^9.14.0",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@kan/eslint-config": "workspace:*",
"@kan/prettier-config": "workspace:*",
"@kan/tsconfig": "workspace:*",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
},
"prettier": "@kan/prettier-config"
}

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