Compare commits
19 Commits
security/a
...
feat/loggi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f6fc66f84 | ||
|
|
3604a49a89 | ||
|
|
2360c5075d | ||
|
|
79c2b5f3d5 | ||
|
|
8e7a95ff2b | ||
|
|
94fd2cb11f | ||
|
|
2a8af514d1 | ||
|
|
0b405e0456 | ||
|
|
f1f3a00c17 | ||
|
|
0fb2bac102 | ||
|
|
a7b71ae764 | ||
|
|
a246514b0b | ||
|
|
53397d8e81 | ||
|
|
1f9f07df20 | ||
|
|
1d5e3a936c | ||
|
|
0b49f502a9 | ||
|
|
400dcec56d | ||
|
|
dfcdc5e47e | ||
|
|
32e77e0291 |
@@ -2,7 +2,7 @@
|
||||
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 26 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
# Fill if you want to use an external database
|
||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||
@@ -28,12 +28,14 @@ S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
# S3_AVATAR_UPLOAD_LIMIT=2097152 # Avatar upload size limit in bytes (default: 2MB)
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=
|
||||
|
||||
|
||||
# Auth config (optional)
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS=
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP=
|
||||
@@ -49,6 +51,9 @@ 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)
|
||||
|
||||
20
AGENTS.md
20
AGENTS.md
@@ -185,9 +185,17 @@ Use `assertUserInWorkspace` helper for workspace checks.
|
||||
|
||||
- Use TRPCError with appropriate codes (UNAUTHORIZED, NOT_FOUND, etc.)
|
||||
- Provide user-friendly error messages
|
||||
- Log errors appropriately
|
||||
- Log errors appropriately using the `@kan/logger` package
|
||||
- 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
|
||||
@@ -219,6 +227,16 @@ 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)
|
||||
|
||||
@@ -203,6 +203,7 @@ pnpm dev
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `S3_AVATAR_UPLOAD_LIMIT` | Maximum avatar file size in bytes | For file uploads | `2097152` (2MB) |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` |
|
||||
@@ -212,6 +213,7 @@ 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.
|
||||
|
||||
|
||||
@@ -39,10 +39,10 @@ checksums:
|
||||
0xWkkH/placeholders/boardCount/0: 8cc7c9ba5252e9266c6eb8434fae00f3
|
||||
0xWkkH/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
0xWkkH/translation: 72358c550bd1a99fcb6e8952f0f947ef
|
||||
yndBF%2B/translation: 9fb995d992256a9db7822d430ad75ba0
|
||||
yndBF%2B/message: 9fb995d992256a9db7822d430ad75ba0
|
||||
yndBF%2B/placeholders/projectCount/0: 23257c47018d21aeae42cf5bd0aa9932
|
||||
yndBF%2B/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
yndBF%2B/translation: 9fb995d992256a9db7822d430ad75ba0
|
||||
9lFfqv/message: 6cb28ff89203e2eca3fdd69396dd00d6
|
||||
9lFfqv/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
9lFfqv/translation: 6cb28ff89203e2eca3fdd69396dd00d6
|
||||
@@ -70,6 +70,9 @@ checksums:
|
||||
gkxGkF/origin/1/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
gkxGkF/origin/2/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
gkxGkF/translation: f5f537ac080c6184864ffaae7a9fcf4f
|
||||
4obmDr/message: 3d918420983fcb0d66a6b3df89a5294e
|
||||
4obmDr/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
4obmDr/translation: 3d918420983fcb0d66a6b3df89a5294e
|
||||
"%2Frn4RE/message": d405b83b0d631cb72f4347c10bcbb643
|
||||
"%2Frn4RE/origin/0/0": 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
"%2Frn4RE/translation": d405b83b0d631cb72f4347c10bcbb643
|
||||
@@ -87,6 +90,8 @@ checksums:
|
||||
7L01XJ/translation: c46571856723b03262fd33f511116298
|
||||
F6pfE9/message: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
F6pfE9/origin/0/0: 4b563046ba98abb3777fb1c3560866aa
|
||||
F6pfE9/origin/1/0: f64cd62b620f454998523af2a4330278
|
||||
F6pfE9/origin/2/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
F6pfE9/translation: 3e1ec025c4a50830bbb9ad57a176630a
|
||||
XJOV1Y/message: 1948763de8e531483a798b68195e297e
|
||||
XJOV1Y/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -128,6 +133,9 @@ checksums:
|
||||
pBsoKL/message: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
pBsoKL/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
pBsoKL/translation: 532155acb0a46f2b6c37e15afb3ad21e
|
||||
cWXW%2B7/message: f8fa42d8c95936075e05ad238fd03467
|
||||
cWXW%2B7/origin/0/0: 566b5982085ae649b2be0c614f666591
|
||||
cWXW%2B7/translation: f8fa42d8c95936075e05ad238fd03467
|
||||
bmXKoX/message: 42b0488d570626dc887c58ff669c953c
|
||||
bmXKoX/placeholders/0/0: ea5a6955d34117338ea90173f2aa1f02
|
||||
bmXKoX/placeholders/labelList/0: 19a2dd6c58e803b03e6c301dc363eed6
|
||||
@@ -215,12 +223,12 @@ checksums:
|
||||
WnEwDO/message: 2959fd276248208b65cb27ed46b20135
|
||||
WnEwDO/origin/0/0: d6f5120f072a821219608624f361384e
|
||||
WnEwDO/translation: 2959fd276248208b65cb27ed46b20135
|
||||
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/message: baf4c3cd580291f41458f9ed28102c49
|
||||
j1%2BHPc/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
|
||||
j1%2BHPc/translation: baf4c3cd580291f41458f9ed28102c49
|
||||
Dz63YI/message: 8e43abcd6e039b51084937e4829f58ad
|
||||
Dz63YI/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Dz63YI/translation: 8e43abcd6e039b51084937e4829f58ad
|
||||
WmPhYW/message: 0aa3973b860c1faf8d9123aebf567e40
|
||||
WmPhYW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
WmPhYW/translation: 0aa3973b860c1faf8d9123aebf567e40
|
||||
@@ -268,6 +276,10 @@ checksums:
|
||||
lo8xBK/placeholders/entityLabel/0: 5745fa1af326b9ac2b140cbbef90cdeb
|
||||
lo8xBK/origin/0/0: 68450df287fb46ea629e818732c1291a
|
||||
lo8xBK/translation: f9b67026c5ef86ac4d5944380d571961
|
||||
Ypy5pZ/message: e4f0c7b9475b179d3856c0fe28aa4f21
|
||||
Ypy5pZ/placeholders/webhookName/0: b1f7ad11b42fd7d0142b9ae1fd269e35
|
||||
Ypy5pZ/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
Ypy5pZ/translation: e4f0c7b9475b179d3856c0fe28aa4f21
|
||||
BhDZlc/message: 27ba2854202dd77c51e78c64e5c93946
|
||||
BhDZlc/placeholders/0/0: 7c9ad8179019e516873e06573980408a
|
||||
BhDZlc/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
@@ -514,8 +526,9 @@ checksums:
|
||||
dEgA5A/origin/9/0: 0627e0040ca4939c9a57349e98c51797
|
||||
dEgA5A/origin/10/0: 15792234f408027822ee71ccddde6f9b
|
||||
dEgA5A/origin/11/0: 5c60161e173f5eae9276d8e4c70b4ebb
|
||||
dEgA5A/origin/12/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/13/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/origin/12/0: a1fde3fcce608870830f168f027727ff
|
||||
dEgA5A/origin/13/0: 83911e3eacbad4583e2b1647a784d154
|
||||
dEgA5A/origin/14/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
dEgA5A/translation: 2e2a849c2223911717de8caa2c71bade
|
||||
kryGs%2B/message: bba0beaced7ea954ceb980f2b022ffee
|
||||
kryGs%2B/origin/0/0: 15fd9c05fc09ccbbb54aadc4aa5e5501
|
||||
@@ -619,6 +632,9 @@ checksums:
|
||||
tOZp9v/message: 3e67f6c507a47ec6e6dd232a34c166ad
|
||||
tOZp9v/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
tOZp9v/translation: 3e67f6c507a47ec6e6dd232a34c166ad
|
||||
t0dTPm/message: 95ec8431a14c39ac2ab308bef3297a53
|
||||
t0dTPm/origin/0/0: 566b5982085ae649b2be0c614f666591
|
||||
t0dTPm/translation: 95ec8431a14c39ac2ab308bef3297a53
|
||||
"%2Ff3t6v/message": 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
"%2Ff3t6v/origin/0/0": 0dccc39d0debbf3e089cb6d292de762b
|
||||
"%2Ff3t6v/translation": 0a46a8a30c6c0ebdcd01e72a7d64ec64
|
||||
@@ -628,21 +644,21 @@ checksums:
|
||||
479pdJ/message: a0d2935d7b63f8dd19d7c0de47524416
|
||||
479pdJ/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
479pdJ/translation: a0d2935d7b63f8dd19d7c0de47524416
|
||||
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
|
||||
iSLIjg/message: 8778ee245078a8be4a2ce855c8c56edc
|
||||
iSLIjg/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
iSLIjg/translation: 8778ee245078a8be4a2ce855c8c56edc
|
||||
3jod3l/message: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
3jod3l/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
3jod3l/translation: 71ca612e433a9ed61ccfbfe1cc87c78b
|
||||
nk7caK/message: 4440a0b9e387ef7136e3958e7a089213
|
||||
nk7caK/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
nk7caK/translation: 4440a0b9e387ef7136e3958e7a089213
|
||||
sDLCvT/message: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
sDLCvT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
sDLCvT/translation: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
MCIXdZ/translation: ad3738e5eeabcebd3478cdc2545f543f
|
||||
MCIXdZ/message: ad3738e5eeabcebd3478cdc2545f543f
|
||||
MCIXdZ/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
MCIXdZ/translation: ad3738e5eeabcebd3478cdc2545f543f
|
||||
5eZwRW/message: 84a510a7485f43e43f2191bd161171d7
|
||||
5eZwRW/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
5eZwRW/translation: 84a510a7485f43e43f2191bd161171d7
|
||||
@@ -729,10 +745,16 @@ checksums:
|
||||
A5hiCy/message: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
A5hiCy/origin/0/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
A5hiCy/translation: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
SiPp29/message: 04816e0980ed5debb74fb92169766590
|
||||
SiPp29/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
SiPp29/translation: 04816e0980ed5debb74fb92169766590
|
||||
FdtSNC/message: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
FdtSNC/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
FdtSNC/origin/1/0: 33acddc2fe10746463583d03146e7956
|
||||
FdtSNC/translation: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
d%2BF6q9/message: 6045b4ff97fb7dca19ebadfa2e34a700
|
||||
d%2BF6q9/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
d%2BF6q9/translation: 6045b4ff97fb7dca19ebadfa2e34a700
|
||||
f8lDFq/message: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
f8lDFq/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
f8lDFq/translation: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
@@ -801,6 +823,8 @@ checksums:
|
||||
cnGeoo/origin/4/0: 2a382c1cdf010f01a8e95c3e05f6df53
|
||||
cnGeoo/origin/5/0: d55f532bb5ae247e3a018978be0da463
|
||||
cnGeoo/origin/6/0: 4b4f55c033add8db69178563832227c6
|
||||
cnGeoo/origin/7/0: a1fde3fcce608870830f168f027727ff
|
||||
cnGeoo/origin/8/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
cnGeoo/translation: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
ZDGm40/message: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
ZDGm40/origin/0/0: 627b72cd19a7154ae1c28cbd7ca0c730
|
||||
@@ -822,6 +846,9 @@ checksums:
|
||||
DFjdv0/message: bdf3e8342f581d498b7238cdd4897a4b
|
||||
DFjdv0/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
DFjdv0/translation: bdf3e8342f581d498b7238cdd4897a4b
|
||||
snMaH4/message: f15f64622d956beb69dce8ec24f237ae
|
||||
snMaH4/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
snMaH4/translation: f15f64622d956beb69dce8ec24f237ae
|
||||
kYu0eF/message: 9017c821209528d9b20d40a488854cdf
|
||||
kYu0eF/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
kYu0eF/origin/1/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
@@ -844,9 +871,9 @@ checksums:
|
||||
f8fH8W/message: 991b75727b6784c1a063a7462b76186d
|
||||
f8fH8W/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
f8fH8W/translation: 991b75727b6784c1a063a7462b76186d
|
||||
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
Odv3J6/message: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
Odv3J6/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
Odv3J6/translation: f3cc49ba2dc3f9c33917f8a749d68bf5
|
||||
YBBifR/message: a2dd737d133887d34504728c7e7f05e3
|
||||
YBBifR/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
YBBifR/translation: a2dd737d133887d34504728c7e7f05e3
|
||||
@@ -918,6 +945,7 @@ checksums:
|
||||
zxKs%2By/translation: 0b6c8ad7aba0873b7e212d5f1949ca97
|
||||
ePK91l/message: eee7f39ff90b18852afc1671f21fbaa9
|
||||
ePK91l/origin/0/0: b697a20a9536be4dd8ecd143461eafc7
|
||||
ePK91l/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
ePK91l/translation: eee7f39ff90b18852afc1671f21fbaa9
|
||||
tIdipJ/message: d8276dfc0189f371ec7d80a2047c07aa
|
||||
tIdipJ/origin/0/0: 0ad2af981579a62b084b3d41462c5546
|
||||
@@ -933,6 +961,9 @@ checksums:
|
||||
TCOMOO/origin/0/0: afee530613110e248ffde6b56872ebed
|
||||
TCOMOO/origin/1/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
TCOMOO/translation: 244558dd716491b7ed72ba8ab73aa28f
|
||||
fW5sSv/message: 145e9993fc334ea0718882c177928c01
|
||||
fW5sSv/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
fW5sSv/translation: 145e9993fc334ea0718882c177928c01
|
||||
klpSmb/message: bbae5f2f8a442947d33099979bbbe899
|
||||
klpSmb/origin/0/0: 2e937f7560901c3de71a592b4fd80f34
|
||||
klpSmb/translation: bbae5f2f8a442947d33099979bbbe899
|
||||
@@ -966,6 +997,9 @@ checksums:
|
||||
V8YWP3/message: f002074db0bd51d4f28d2736e140370a
|
||||
V8YWP3/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
V8YWP3/translation: f002074db0bd51d4f28d2736e140370a
|
||||
rQRXo8/message: 35ee0f41d7713e4497a4237db9a20264
|
||||
rQRXo8/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
rQRXo8/translation: 35ee0f41d7713e4497a4237db9a20264
|
||||
fR9laE/message: bfceabde4c0b6f2cb439015b76549651
|
||||
fR9laE/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
fR9laE/translation: bfceabde4c0b6f2cb439015b76549651
|
||||
@@ -996,9 +1030,9 @@ checksums:
|
||||
55xJ%2FO/message: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
55xJ%2FO/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
55xJ%2FO/translation: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
yhLUU8/translation: e879ffdeccbe5b00fa7ece09114d149f
|
||||
yhLUU8/message: e879ffdeccbe5b00fa7ece09114d149f
|
||||
yhLUU8/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
yhLUU8/translation: e879ffdeccbe5b00fa7ece09114d149f
|
||||
cji2nM/message: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
cji2nM/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
cji2nM/translation: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
@@ -1014,9 +1048,9 @@ checksums:
|
||||
9s9O5h/message: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
9s9O5h/origin/0/0: 83911e3eacbad4583e2b1647a784d154
|
||||
9s9O5h/translation: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
gQ%2F02p/translation: 08e8dfeafa38796abcdcc258917428d6
|
||||
gQ%2F02p/message: 08e8dfeafa38796abcdcc258917428d6
|
||||
gQ%2F02p/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
gQ%2F02p/translation: 08e8dfeafa38796abcdcc258917428d6
|
||||
lKAvEd/message: a8766853864eddc480113778a708871b
|
||||
lKAvEd/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
lKAvEd/translation: a8766853864eddc480113778a708871b
|
||||
@@ -1047,6 +1081,10 @@ checksums:
|
||||
CYWHT%2B/origin/0/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
CYWHT%2B/origin/1/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
CYWHT%2B/translation: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
tst44n/message: d17179a947e9687dd2338ca524b26b63
|
||||
tst44n/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
tst44n/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
tst44n/translation: d17179a947e9687dd2338ca524b26b63
|
||||
8iP0S8/message: 62b44c4973b92b806c69a4b15e0256dc
|
||||
8iP0S8/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
8iP0S8/translation: 62b44c4973b92b806c69a4b15e0256dc
|
||||
@@ -1069,6 +1107,12 @@ checksums:
|
||||
53QYh8/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
53QYh8/origin/1/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
53QYh8/translation: a746e2afe881c3bf0a8e82a931ca3495
|
||||
o4sQAg/message: 97fd378ebf70814753414b035fe5639e
|
||||
o4sQAg/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
o4sQAg/translation: 97fd378ebf70814753414b035fe5639e
|
||||
9YPBHv/message: 87429c06e5895397f325c4087378050a
|
||||
9YPBHv/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
9YPBHv/translation: 87429c06e5895397f325c4087378050a
|
||||
W8oT%2BL/message: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
W8oT%2BL/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
W8oT%2BL/translation: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
|
||||
@@ -1076,6 +1120,13 @@ checksums:
|
||||
YtUfwW/placeholders/0/0: 8085cc5670186c803eac6c957d57ea21
|
||||
YtUfwW/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
YtUfwW/translation: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
5ntVtp/message: d3052fcc32ff4f12415ea6687a97303e
|
||||
5ntVtp/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
5ntVtp/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
5ntVtp/translation: d3052fcc32ff4f12415ea6687a97303e
|
||||
R41XLH/message: 65f12e9667e1a3c968541fe96ffbf7c7
|
||||
R41XLH/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
R41XLH/translation: 65f12e9667e1a3c968541fe96ffbf7c7
|
||||
ZL1A%2Bp/message: f8a50d1c8491404f73d3cf701e11f297
|
||||
ZL1A%2Bp/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
ZL1A%2Bp/translation: f8a50d1c8491404f73d3cf701e11f297
|
||||
@@ -1184,12 +1235,12 @@ checksums:
|
||||
RkXlPZ/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
RkXlPZ/origin/1/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
RkXlPZ/translation: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
|
||||
PVT7q7/message: 8fa30040ec891db0b5b7d893786514ab
|
||||
PVT7q7/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
|
||||
PVT7q7/translation: 8fa30040ec891db0b5b7d893786514ab
|
||||
"%2FbBVaD/message": 79b9a6153d695ff0175aa804b1c0286b
|
||||
"%2FbBVaD/origin/0/0": 9d4260644f1a12f41d6b72574eeb111c
|
||||
"%2FbBVaD/translation": 79b9a6153d695ff0175aa804b1c0286b
|
||||
7eMo%2BU/message: 6251589da1964d55afabdfd64c84c335
|
||||
7eMo%2BU/origin/0/0: 6cda8b872e4902cbc0191d9375adf4ef
|
||||
7eMo%2BU/translation: 6251589da1964d55afabdfd64c84c335
|
||||
@@ -1199,7 +1250,11 @@ checksums:
|
||||
UveK6V/translation: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
mgXgHu/message: dc1362de207d67fe84fb0caf60262e73
|
||||
mgXgHu/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
mgXgHu/origin/1/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
mgXgHu/translation: dc1362de207d67fe84fb0caf60262e73
|
||||
XEoGkQ/message: 1cdeef0fc0f3ab18a88f34a557032582
|
||||
XEoGkQ/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
XEoGkQ/translation: 1cdeef0fc0f3ab18a88f34a557032582
|
||||
KAsRdK/message: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
KAsRdK/origin/0/0: 94d3e20c29bb517dd394711d7524e478
|
||||
KAsRdK/translation: 445f4efbc4b1e7509f4fd79ebfbb1476
|
||||
@@ -1226,6 +1281,9 @@ checksums:
|
||||
XXbgHS/message: e5a9b1bd409b007141fe3d7890022f9a
|
||||
XXbgHS/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
XXbgHS/translation: e5a9b1bd409b007141fe3d7890022f9a
|
||||
SRvxId/message: e356e36ab5a3e30e81f132dd9abb5483
|
||||
SRvxId/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
SRvxId/translation: e356e36ab5a3e30e81f132dd9abb5483
|
||||
LrcnbT/message: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
LrcnbT/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
LrcnbT/translation: 8e7ae0783d60ef4624d3caf9bfc3747f
|
||||
@@ -1293,6 +1351,9 @@ checksums:
|
||||
WbTDwg/origin/3/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
WbTDwg/origin/4/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
WbTDwg/translation: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
NoNwIX/message: 239e3b722db75bbafc6c628a2685eed2
|
||||
NoNwIX/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NoNwIX/translation: 239e3b722db75bbafc6c628a2685eed2
|
||||
6mbe4b/message: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
6mbe4b/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
6mbe4b/translation: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
@@ -1476,6 +1537,9 @@ checksums:
|
||||
"%2FbZzdR/message": 40ea106c56d79634e058e087ad322025
|
||||
"%2FbZzdR/origin/0/0": c0b59968b2b234167c488a05cc38710b
|
||||
"%2FbZzdR/translation": 40ea106c56d79634e058e087ad322025
|
||||
hty0d5/translation: b4b1c8396e445e8b7956c747da2cf601
|
||||
hty0d5/message: b4b1c8396e445e8b7956c747da2cf601
|
||||
hty0d5/origin/0/0: 04c86ea96f180745739d59e822da4026
|
||||
"%2B8Nek%2F/message": 818f1192e32bb855597f930d3e78806e
|
||||
"%2B8Nek%2F/origin/0/0": 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
"%2B8Nek%2F/origin/1/0": 82a5cf8d69f098641eb357bf102cd504
|
||||
@@ -1491,10 +1555,15 @@ checksums:
|
||||
T7LJic/message: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
T7LJic/origin/0/0: 1d6b5f88cdad160a67842f6b65387448
|
||||
T7LJic/translation: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
uEGGDs/message: 844d23d8391d57110f8b03a14a35cf00
|
||||
uEGGDs/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
uEGGDs/translation: 844d23d8391d57110f8b03a14a35cf00
|
||||
6YtxFj/message: 9368b5a047572b6051f334af5aa76819
|
||||
6YtxFj/origin/0/0: d40778923d288b8a81f4cabac4898d43
|
||||
6YtxFj/origin/1/0: 3161dd30737e7d849d4ccc30a28bada9
|
||||
6YtxFj/origin/2/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
6YtxFj/origin/3/0: f64cd62b620f454998523af2a4330278
|
||||
6YtxFj/origin/4/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
6YtxFj/translation: 9368b5a047572b6051f334af5aa76819
|
||||
UxKoFf/message: 0373afd8238db1c49f4be4fa6cdf5cd3
|
||||
UxKoFf/origin/0/0: feacdaf2791aaddec128f463ffa2e807
|
||||
@@ -1541,6 +1610,9 @@ checksums:
|
||||
RJA%2Bsg/message: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
RJA%2Bsg/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
RJA%2Bsg/translation: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
sbNNyi/message: a9f91ae6676523cbda6e7373d97eaee5
|
||||
sbNNyi/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
sbNNyi/translation: a9f91ae6676523cbda6e7373d97eaee5
|
||||
curoK5/message: 8eecf05b4ce29aa880adfed2e11476af
|
||||
curoK5/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
curoK5/translation: 8eecf05b4ce29aa880adfed2e11476af
|
||||
@@ -1581,9 +1653,9 @@ checksums:
|
||||
fvLNDy/message: f18ee3d7230cc33b68bd17b429d1d442
|
||||
fvLNDy/origin/0/0: fc0c18a095a68277a0f90a115942683b
|
||||
fvLNDy/translation: f18ee3d7230cc33b68bd17b429d1d442
|
||||
i30J2U/translation: 8b7ec59963fb26e13948600c55c8de1d
|
||||
i30J2U/message: 8b7ec59963fb26e13948600c55c8de1d
|
||||
i30J2U/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
i30J2U/translation: 8b7ec59963fb26e13948600c55c8de1d
|
||||
ERpq2P/message: 5db6294712528cd897b15ae36f4fd834
|
||||
ERpq2P/placeholders/debouncedQuery/0: 8471823afb4210cf564ca0d8bef9dd66
|
||||
ERpq2P/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
@@ -1591,6 +1663,9 @@ checksums:
|
||||
Q9pQaX/message: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
Q9pQaX/origin/0/0: 7f5f72488c743ec0795cf10058ce3351
|
||||
Q9pQaX/translation: 2eb502333aaf6c58e8ab23d881e2e357
|
||||
TX0bT7/message: a7afbc2487022f17b6d993f556fd2735
|
||||
TX0bT7/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
TX0bT7/translation: a7afbc2487022f17b6d993f556fd2735
|
||||
9h7RDh/message: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
9h7RDh/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
9h7RDh/translation: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
@@ -1640,6 +1715,9 @@ checksums:
|
||||
SPLN9O/message: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
SPLN9O/origin/0/0: 9737a9cef0646ff4bbd942b1924b9a2c
|
||||
SPLN9O/translation: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
8F1i42/message: 97612e6230bc7a1ebd99380bf561b732
|
||||
8F1i42/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
8F1i42/translation: 97612e6230bc7a1ebd99380bf561b732
|
||||
Uworke/message: 213d63da450f35dabb3ab0e35e29feed
|
||||
Uworke/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
Uworke/translation: 213d63da450f35dabb3ab0e35e29feed
|
||||
@@ -1718,6 +1796,9 @@ checksums:
|
||||
7b2yuC/message: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
7b2yuC/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
7b2yuC/translation: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
jEw0Mr/message: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
jEw0Mr/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
jEw0Mr/translation: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
tmlJN1/message: c16c69c3b742b1e19148378d50adf37f
|
||||
tmlJN1/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
tmlJN1/origin/1/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
@@ -1932,11 +2013,17 @@ checksums:
|
||||
YEOVrT/message: 86b76024524fc585b2c3950126ef6f62
|
||||
YEOVrT/origin/0/0: fadd5a5b6963ec5f261072f7e8d2140b
|
||||
YEOVrT/translation: 86b76024524fc585b2c3950126ef6f62
|
||||
"%2B5kO8P/translation": 77b65adf8ae2b69eaed51b6104ded65e
|
||||
"%2B5kO8P/message": 77b65adf8ae2b69eaed51b6104ded65e
|
||||
"%2B5kO8P/origin/0/0": 04c86ea96f180745739d59e822da4026
|
||||
tfDRzk/message: f7a2929f33bc420195e59ac5a8bcd454
|
||||
tfDRzk/origin/0/0: a50c4fbde8da6e0a77c4e0aef0b44dea
|
||||
tfDRzk/origin/1/0: b2059fa4b8b99cc10440da26475b234c
|
||||
tfDRzk/origin/2/0: 03c00909b41be01231cd3e843c8ee44e
|
||||
tfDRzk/translation: f7a2929f33bc420195e59ac5a8bcd454
|
||||
y3aU20/message: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
y3aU20/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
y3aU20/translation: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
MdwUGG/message: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
MdwUGG/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
MdwUGG/translation: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
@@ -1946,6 +2033,12 @@ checksums:
|
||||
SkCNhl/message: 137017b2b3e885c4894ca8c899af5bc2
|
||||
SkCNhl/origin/0/0: 89a76a14edc1c0b7876d79d7571bb8fb
|
||||
SkCNhl/translation: 137017b2b3e885c4894ca8c899af5bc2
|
||||
e1v%2BJ3/message: 9aeca4f286e14966ef1c32d2abc74584
|
||||
e1v%2BJ3/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
e1v%2BJ3/translation: 9aeca4f286e14966ef1c32d2abc74584
|
||||
OkTY4%2B/message: 5deb2ff7c984db2d69457d24c3dd7d0a
|
||||
OkTY4%2B/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
OkTY4%2B/translation: 5deb2ff7c984db2d69457d24c3dd7d0a
|
||||
a3LDKx/message: 4b34923fef858a2b9a4a914c3e822889
|
||||
a3LDKx/origin/0/0: c0b59968b2b234167c488a05cc38710b
|
||||
a3LDKx/translation: 4b34923fef858a2b9a4a914c3e822889
|
||||
@@ -1956,6 +2049,9 @@ checksums:
|
||||
wgNoIs/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/origin/1/0: 3c57a918258af4b44c187f58d203345f
|
||||
wgNoIs/translation: eedc7cdb02de467c15dc418a066a77f2
|
||||
o4e%2F70/message: d39f10456ad4ef683c1d0161e9b87521
|
||||
o4e%2F70/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
o4e%2F70/translation: d39f10456ad4ef683c1d0161e9b87521
|
||||
rYUZIe/message: c7b0ec447415ff62856803a4d7838bd6
|
||||
rYUZIe/origin/0/0: 3c57a918258af4b44c187f58d203345f
|
||||
rYUZIe/translation: c7b0ec447415ff62856803a4d7838bd6
|
||||
@@ -1971,6 +2067,9 @@ checksums:
|
||||
RoafuO/message: 9631cc08d49da04475b30a0d320ce97c
|
||||
RoafuO/origin/0/0: 1fd8ccf586705354bcae6e1186108fc7
|
||||
RoafuO/translation: 9631cc08d49da04475b30a0d320ce97c
|
||||
eus61c/message: fb230b27a8a91d7e597d6579466246b8
|
||||
eus61c/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
eus61c/translation: fb230b27a8a91d7e597d6579466246b8
|
||||
v3YoMA/message: 3fff865dc00435f82896fc302ea45630
|
||||
v3YoMA/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
v3YoMA/translation: 3fff865dc00435f82896fc302ea45630
|
||||
@@ -1999,6 +2098,9 @@ checksums:
|
||||
F%2FFPk%2F/message: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
F%2FFPk%2F/origin/0/0: 0dccc39d0debbf3e089cb6d292de762b
|
||||
F%2FFPk%2F/translation: 8aa60ed978b9f45a705d99dc1ee04f37
|
||||
"%2Bh2faV/message": 402ab0ca17c732457498d09a83dbe8ce
|
||||
"%2Bh2faV/origin/0/0": 566b5982085ae649b2be0c614f666591
|
||||
"%2Bh2faV/translation": 402ab0ca17c732457498d09a83dbe8ce
|
||||
Ci%2FKUX/message: 5d0bacf7ff696da940f232df45edfd39
|
||||
Ci%2FKUX/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
Ci%2FKUX/translation: 5d0bacf7ff696da940f232df45edfd39
|
||||
@@ -2056,6 +2158,9 @@ checksums:
|
||||
Y8yzGg/message: 396a94648584067cc5795f7b32d3d663
|
||||
Y8yzGg/origin/0/0: 82a5cf8d69f098641eb357bf102cd504
|
||||
Y8yzGg/translation: 396a94648584067cc5795f7b32d3d663
|
||||
uAQUqI/message: 4e1fcce15854d824919b4a582c697c90
|
||||
uAQUqI/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
uAQUqI/translation: 4e1fcce15854d824919b4a582c697c90
|
||||
WYDptz/message: 05f2b4abfc36def17756a6969983cf86
|
||||
WYDptz/origin/0/0: 8628ddb6f4ded25c0f17e2cbda8207d3
|
||||
WYDptz/translation: 05f2b4abfc36def17756a6969983cf86
|
||||
@@ -2063,6 +2168,9 @@ checksums:
|
||||
zzDlyQ/origin/0/0: d96af8a06c2e38a72238bcd16e426859
|
||||
zzDlyQ/origin/1/0: d96af8a06c2e38a72238bcd16e426859
|
||||
zzDlyQ/translation: c43827becada6750f7a25890905f38b9
|
||||
DBC3t5/translation: d21934fb36dd8a82e36352968207d44e
|
||||
DBC3t5/message: d21934fb36dd8a82e36352968207d44e
|
||||
DBC3t5/origin/0/0: 04c86ea96f180745739d59e822da4026
|
||||
uZg2%2Bw/message: 21af8119141afcce5f12da489f34db6a
|
||||
uZg2%2Bw/origin/0/0: dbad0c8d7863cb41f5495264f82e7081
|
||||
uZg2%2Bw/translation: 21af8119141afcce5f12da489f34db6a
|
||||
@@ -2108,6 +2216,13 @@ checksums:
|
||||
UCLeG0/message: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
UCLeG0/origin/0/0: 98e2302d0d8d12ce65c4140b87450673
|
||||
UCLeG0/translation: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
NnH3pK/message: 82a46f197779af4dde92e7e5f9336ccf
|
||||
NnH3pK/origin/0/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
NnH3pK/translation: 82a46f197779af4dde92e7e5f9336ccf
|
||||
itgYbC/message: efe14181bc517316e3ab3258d72d2ff9
|
||||
itgYbC/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
itgYbC/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
itgYbC/translation: efe14181bc517316e3ab3258d72d2ff9
|
||||
VUZDlr/message: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
VUZDlr/origin/0/0: 5e2ed4920ffd2f535ece848ff44900bd
|
||||
VUZDlr/translation: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
@@ -2136,6 +2251,9 @@ checksums:
|
||||
gUX7b%2B/message: 692cb2e8a8e610953c996826beed45a2
|
||||
gUX7b%2B/origin/0/0: 09a6a32f9a6b415e81b4d2f8181a0e6b
|
||||
gUX7b%2B/translation: 692cb2e8a8e610953c996826beed45a2
|
||||
0xD8Of/message: 65ad307ba26227fe44343f17e52138de
|
||||
0xD8Of/origin/0/0: 01b2fdc8e77a462c0ab211caecec400f
|
||||
0xD8Of/translation: 65ad307ba26227fe44343f17e52138de
|
||||
fQCrjt/message: 970e17a115f7374e60e0a8ded425db8f
|
||||
fQCrjt/placeholders/0/0: ed822bd0ada0a2e8d894cac37b70c346
|
||||
fQCrjt/origin/0/0: d6920b20329a9efe5985a39a295d83e1
|
||||
@@ -2196,9 +2314,9 @@ checksums:
|
||||
4gAX8s/message: 29dea3e0b6238874f8c7a27619df8e36
|
||||
4gAX8s/origin/0/0: 634473ef965836871efe40875a36b6ab
|
||||
4gAX8s/translation: 29dea3e0b6238874f8c7a27619df8e36
|
||||
L5WQLg/translation: 38a978eab728978612d1dee8bdeb915b
|
||||
L5WQLg/message: 38a978eab728978612d1dee8bdeb915b
|
||||
L5WQLg/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
L5WQLg/translation: 38a978eab728978612d1dee8bdeb915b
|
||||
eEQyz%2B/message: 0d3bac559c71ec4b8734f9f212320de5
|
||||
eEQyz%2B/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
eEQyz%2B/translation: 0d3bac559c71ec4b8734f9f212320de5
|
||||
@@ -2418,10 +2536,17 @@ checksums:
|
||||
BBUDfW/origin/0/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
BBUDfW/origin/1/0: eb63312c63f6c8d2a5b6520c89012123
|
||||
BBUDfW/translation: e79afcd4398e52e10e7e8243a7eecabe
|
||||
IagCbF/message: ca97457614226960d41dd18c3c29c86b
|
||||
IagCbF/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
IagCbF/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
IagCbF/translation: ca97457614226960d41dd18c3c29c86b
|
||||
MI5OBT/message: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
MI5OBT/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
MI5OBT/origin/1/0: 30791e26a172d47d3cb0693e82679246
|
||||
MI5OBT/translation: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
7aONWr/message: 96af2ad24e17a1fe98a79e0a140a1d11
|
||||
7aONWr/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
7aONWr/translation: 96af2ad24e17a1fe98a79e0a140a1d11
|
||||
i5rE2%2F/message: 7f3c9ded3f8b8765809ac7dd9505c0b0
|
||||
i5rE2%2F/origin/0/0: ff4a160eca5f8da4f075fd1dd6061fa6
|
||||
i5rE2%2F/origin/1/0: 30791e26a172d47d3cb0693e82679246
|
||||
@@ -2433,6 +2558,9 @@ checksums:
|
||||
QNtP5M/message: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
QNtP5M/origin/0/0: bcd95e286f12800a3e7ee0a71feb3ca5
|
||||
QNtP5M/translation: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
n6EWYz/message: dc868fbeff71dc275c8b0635c9f97a5a
|
||||
n6EWYz/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
n6EWYz/translation: dc868fbeff71dc275c8b0635c9f97a5a
|
||||
7PzzBU/message: 61073457a5c3901084b557d065f876be
|
||||
7PzzBU/origin/0/0: 7493592314bc3b96ad96127a949ba1ba
|
||||
7PzzBU/translation: 61073457a5c3901084b557d065f876be
|
||||
@@ -2472,6 +2600,35 @@ checksums:
|
||||
9y1RcT/message: 2dfa121e0ff1914e08068308a81973c0
|
||||
9y1RcT/origin/0/0: d77eb1f90f2fa86a55181629d88d237a
|
||||
9y1RcT/translation: 2dfa121e0ff1914e08068308a81973c0
|
||||
GdWB%2BV/message: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
GdWB%2BV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
GdWB%2BV/translation: 6a15a9b926ecd2c91454e9da19c6049e
|
||||
2X4ecw/message: fcefd247ec76a372002d2cffac3c5b0f
|
||||
2X4ecw/origin/0/0: a1fde3fcce608870830f168f027727ff
|
||||
2X4ecw/translation: fcefd247ec76a372002d2cffac3c5b0f
|
||||
jIuWsV/message: 003dddfd1d3ed7c94ec0027e5bb9d496
|
||||
jIuWsV/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
jIuWsV/translation: 003dddfd1d3ed7c94ec0027e5bb9d496
|
||||
U2%2Brn0/message: b94fc9ad83b267fcccacd0f4c529148b
|
||||
U2%2Brn0/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
U2%2Brn0/translation: b94fc9ad83b267fcccacd0f4c529148b
|
||||
8iP9oQ/message: 5c07446c838d07572c6b40621507e951
|
||||
8iP9oQ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
8iP9oQ/origin/1/0: dc1a240d2d0cccb102c67ca311d4e457
|
||||
8iP9oQ/translation: 5c07446c838d07572c6b40621507e951
|
||||
3d54Wj/message: d67dc42810694fd1344a9a9545aca1e7
|
||||
3d54Wj/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
3d54Wj/translation: d67dc42810694fd1344a9a9545aca1e7
|
||||
Hf1cEZ/message: 4b2d60dd7d86dafbf32155a5246a1d40
|
||||
Hf1cEZ/origin/0/0: f64cd62b620f454998523af2a4330278
|
||||
Hf1cEZ/translation: 4b2d60dd7d86dafbf32155a5246a1d40
|
||||
v1kQyJ/message: 0329bc102d3ab3c73410d126e119de12
|
||||
v1kQyJ/origin/0/0: 77c9f6139024b2069e4fdc5120f69cb5
|
||||
v1kQyJ/origin/1/0: 566b5982085ae649b2be0c614f666591
|
||||
v1kQyJ/translation: 0329bc102d3ab3c73410d126e119de12
|
||||
xLTZVf/translation: ce928c74255344905f5c3f02f44c991e
|
||||
xLTZVf/message: ce928c74255344905f5c3f02f44c991e
|
||||
xLTZVf/origin/0/0: 5f6c4a0c9be5098099167871ae1564af
|
||||
9eF5oV/message: 4928884739ba559e6e4b960e80fe1452
|
||||
9eF5oV/origin/0/0: 4a1be3707d14c696a16f453d132fb248
|
||||
9eF5oV/translation: 4928884739ba559e6e4b960e80fe1452
|
||||
@@ -2615,24 +2772,24 @@ checksums:
|
||||
tca56%2F/message: 19d84e934877efc0fef285ff3d6e7849
|
||||
tca56%2F/origin/0/0: 12c1fab543c8848bcb10107d47336736
|
||||
tca56%2F/translation: 19d84e934877efc0fef285ff3d6e7849
|
||||
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
|
||||
HcT8J4/message: 19478f9152e6879c9c029198e635b44d
|
||||
HcT8J4/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
HcT8J4/translation: 19478f9152e6879c9c029198e635b44d
|
||||
AdxYds/message: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
AdxYds/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
AdxYds/translation: a265e2d3b01efd3fe04ac251d2ff6afc
|
||||
PELbXB/message: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
PELbXB/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
PELbXB/translation: 6984aed03bbcd94041c5dd20f62e56ed
|
||||
Ozt2vv/message: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Ozt2vv/origin/0/0: 0627e0040ca4939c9a57349e98c51797
|
||||
Ozt2vv/translation: 8f730f68d52b7efa727aa0784282ebcb
|
||||
"%2FtOdd2/message": 099e1780348cd7bf617721344371a430
|
||||
"%2FtOdd2/origin/0/0": 03c00909b41be01231cd3e843c8ee44e
|
||||
"%2FtOdd2/translation": 099e1780348cd7bf617721344371a430
|
||||
"%2BjbXzb/translation": c07e27183a52361fc05b919c2a3f5c1f
|
||||
"%2BjbXzb/message": c07e27183a52361fc05b919c2a3f5c1f
|
||||
"%2BjbXzb/origin/0/0": 3c57a918258af4b44c187f58d203345f
|
||||
"%2BjbXzb/translation": c07e27183a52361fc05b919c2a3f5c1f
|
||||
CJWDTP/message: 126aeeb73f7e4cad338056e4c933f39b
|
||||
CJWDTP/origin/0/0: 9d4260644f1a12f41d6b72574eeb111c
|
||||
CJWDTP/translation: 126aeeb73f7e4cad338056e4c933f39b
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@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",
|
||||
|
||||
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
39
apps/web/public/manifest.json
Normal file
39
apps/web/public/manifest.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"background_color": "#000000",
|
||||
"categories": [
|
||||
"business",
|
||||
"productivity",
|
||||
"utilities"
|
||||
],
|
||||
"description": "The open source Trello alternative.",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"sizes": "512x512",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"id": "/",
|
||||
"screenshots": [
|
||||
{
|
||||
"sizes": "1624x1561",
|
||||
"src": "/screenshot-wide.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
},
|
||||
{
|
||||
"sizes": "1290x2796",
|
||||
"src": "/screenshot-narrow.png",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"orientation": "portrait-primary",
|
||||
"name": "Kan.bn | The open source alternative to Trello",
|
||||
"scope": "/",
|
||||
"short_name": "Kan.bn",
|
||||
"start_url": "/",
|
||||
"theme_color": "#000000"
|
||||
}
|
||||
BIN
apps/web/public/screenshot-narrow.png
Normal file
BIN
apps/web/public/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
BIN
apps/web/public/screenshot-wide.png
Normal file
BIN
apps/web/public/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -145,7 +145,6 @@ 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("");
|
||||
|
||||
@@ -17,9 +17,14 @@ import { twMerge } from "tailwind-merge";
|
||||
interface DateSelectorProps {
|
||||
selectedDate?: Date | null;
|
||||
onDateSelect?: (date: Date | undefined) => void;
|
||||
weekStartsOn?: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const DateSelector = ({
|
||||
selectedDate,
|
||||
onDateSelect,
|
||||
weekStartsOn = 1,
|
||||
}: DateSelectorProps) => {
|
||||
const [currentMonth, setCurrentMonth] = useState(() => {
|
||||
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
|
||||
});
|
||||
@@ -28,18 +33,18 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
const year = format(currentMonth, "yyyy");
|
||||
|
||||
const dayHeaders = useMemo(() => {
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
|
||||
const weekStart = startOfWeek(new Date(), { weekStartsOn });
|
||||
return eachDayOfInterval({
|
||||
start: weekStart,
|
||||
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
|
||||
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
|
||||
}, []);
|
||||
}, [weekStartsOn]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
|
||||
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
|
||||
(date) => {
|
||||
@@ -53,7 +58,7 @@ const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [currentMonth, selectedDate]);
|
||||
}, [currentMonth, selectedDate, weekStartsOn]);
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
|
||||
@@ -75,7 +75,7 @@ export function LabelForm({
|
||||
reset(newFormState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -231,9 +231,7 @@ 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>
|
||||
|
||||
@@ -111,6 +111,7 @@ export function NewWorkspaceForm() {
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
|
||||
@@ -8,6 +8,7 @@ export const PageHead = ({ title }: { title: string }) => {
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiChevronDown,
|
||||
HiOutlineBanknotes,
|
||||
HiOutlineBolt,
|
||||
HiOutlineCodeBracketSquare,
|
||||
HiOutlineRectangleGroup,
|
||||
HiOutlineShieldCheck,
|
||||
@@ -64,6 +65,12 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||
label: t`API`,
|
||||
condition: true,
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
icon: <HiOutlineBolt />,
|
||||
label: t`Webhooks`,
|
||||
condition: isAdmin,
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
|
||||
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
44
apps/web/src/hooks/useScrollRestore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { NextRouter } from "next/router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const scrollPositions = new Map<string, number>();
|
||||
|
||||
export function useScrollRestore(
|
||||
boardId: string | null | undefined,
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
router: NextRouter,
|
||||
isReady: boolean,
|
||||
) {
|
||||
const restored = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardId) return;
|
||||
|
||||
restored.current = false;
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
if (scrollRef.current) {
|
||||
scrollPositions.set(boardId, scrollRef.current.scrollLeft);
|
||||
}
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", saveScrollPosition);
|
||||
return () => router.events.off("routeChangeStart", saveScrollPosition);
|
||||
}, [boardId, router.events, scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restored.current || !isReady || !boardId) return;
|
||||
restored.current = true;
|
||||
|
||||
const saved = scrollPositions.get(boardId);
|
||||
if (saved === undefined) return;
|
||||
|
||||
// StrictModeDroppable delays rendering by one requestAnimationFrame
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = saved;
|
||||
});
|
||||
});
|
||||
}, [isReady, scrollRef, boardId]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
55
apps/web/src/pages/404.tsx
Normal file
55
apps/web/src/pages/404.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`404 - Page Not Found | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-4 text-8xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
404
|
||||
</p>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
<Trans>Page not found</Trans>
|
||||
</p>
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="mb-6 text-center text-light-900 dark:text-dark-900">
|
||||
<Trans>
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-100 shadow-sm hover:bg-light-900 dark:bg-dark-1000 dark:text-dark-100 dark:hover:bg-dark-900"
|
||||
>
|
||||
<Trans>Go to homepage</Trans>
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className="flex w-full justify-center rounded-md border border-light-500 bg-light-100 px-3 py-2 text-sm font-semibold text-light-1000 shadow-sm hover:bg-light-200 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
<Trans>Go to boards</Trans>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { url, filename } = req.query;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({
|
||||
message: "url parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename = typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
message: "Failed to fetch attachment",
|
||||
});
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
const { url, filename } = req.query;
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({ message: "url parameter is required" });
|
||||
}
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
const s3Endpoint = env.S3_ENDPOINT;
|
||||
|
||||
if (s3Endpoint) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return res.status(400).json({ message: "Invalid URL" });
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
let allowedHost: string;
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
return res.status(403).json({ message: "URL not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadFilename =
|
||||
typeof filename === "string"
|
||||
? encodeURIComponent(filename)
|
||||
: "attachment";
|
||||
|
||||
const upstream = await fetch(url);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res
|
||||
.status(upstream.status)
|
||||
.json({ message: "Failed to fetch attachment" });
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,8 +3,11 @@ 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) {
|
||||
@@ -41,6 +44,8 @@ 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;
|
||||
@@ -56,12 +61,12 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||
}
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("Webhook error:", err);
|
||||
log.error({ err }, "Stripe webhook handler failed");
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TrelloAuthorize() {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
16
apps/web/src/pages/settings/webhooks.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||
import WebhookSettings from "~/views/settings/WebhookSettings";
|
||||
|
||||
const WebhookSettingsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<SettingsLayout currentTab="webhooks">
|
||||
<WebhookSettings />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
WebhookSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default WebhookSettingsPage;
|
||||
@@ -19,6 +19,7 @@ interface Workspace {
|
||||
slug: string | undefined;
|
||||
plan: "free" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -28,13 +29,14 @@ const initialWorkspace: Workspace = {
|
||||
slug: "",
|
||||
plan: "free",
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
|
||||
export const WorkspaceContext = createContext<WorkspaceContextProps | undefined>(
|
||||
undefined,
|
||||
);
|
||||
export const WorkspaceContext = createContext<
|
||||
WorkspaceContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
@@ -79,6 +81,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
slug: workspace.slug,
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -100,6 +103,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
@@ -119,6 +123,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
@@ -24,6 +24,7 @@ import Toggle from "~/components/Toggle";
|
||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -53,6 +54,7 @@ export function NewCardForm({
|
||||
queryParams,
|
||||
}: NewCardFormProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -491,6 +493,7 @@ export function NewCardForm({
|
||||
setValue("dueDate", date ?? null);
|
||||
setIsDateSelectorOpen(false);
|
||||
}}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -124,11 +125,21 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
data: boardData,
|
||||
isSuccess,
|
||||
isLoading: isQueryLoading,
|
||||
error,
|
||||
} = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Redirect to 404 if board doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && boardId && !isQueryLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, boardId, isQueryLoading, error, boardData]);
|
||||
|
||||
const refetchBoard = async () => {
|
||||
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
|
||||
};
|
||||
@@ -141,6 +152,8 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
|
||||
@@ -234,7 +234,7 @@ const ImportGithub: React.FC = () => {
|
||||
await refetchBoards();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
@@ -386,7 +386,7 @@ const ImportTrello: React.FC = () => {
|
||||
await refetchBoards();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function DueDateSelector({
|
||||
disabled = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
||||
@@ -135,6 +137,7 @@ export function DueDateSelector({
|
||||
<DateSelector
|
||||
selectedDate={pendingDate ?? undefined}
|
||||
onDateSelect={handleDateSelect}
|
||||
weekStartsOn={workspace.weekStartDay}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -185,11 +185,20 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && cardId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!card && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, cardId, isLoading, error, card]);
|
||||
|
||||
const isCreator = card?.createdBy && session?.user.id === card.createdBy;
|
||||
const canEdit = canEditCard || isCreator;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function CardModal({
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
const { data, isLoading, error } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId: cardPublicId ?? "",
|
||||
},
|
||||
@@ -56,6 +56,17 @@ export function CardModal({
|
||||
},
|
||||
);
|
||||
|
||||
// Redirect to 404 if card doesn't exist
|
||||
useEffect(() => {
|
||||
if (isOpen && cardPublicId && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading && error)) {
|
||||
// Close modal first, then redirect
|
||||
closeModal();
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [isOpen, cardPublicId, isLoading, error, data, closeModal, router]);
|
||||
|
||||
const labels = data?.labels ?? [];
|
||||
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
@@ -12,13 +13,22 @@ export default function PublicBoardsView() {
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const { data, isLoading } = api.workspace.bySlug.useQuery(
|
||||
const { data, isLoading, error } = api.workspace.bySlug.useQuery(
|
||||
{
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
},
|
||||
{ enabled: !!workspaceSlug },
|
||||
);
|
||||
|
||||
// Redirect to 404 if workspace doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && workspaceSlug && !isLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!data && !isLoading)) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
}, [router, workspaceSlug, isLoading, error, data]);
|
||||
|
||||
const BoardsList = ({
|
||||
isLoading,
|
||||
boards,
|
||||
|
||||
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal file
78
apps/web/src/views/settings/WebhookSettings.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { DeleteWebhookConfirmation } from "./components/DeleteWebhookConfirmation";
|
||||
import { NewWebhookModal } from "./components/NewWebhookModal";
|
||||
import WebhookList from "./components/WebhookList";
|
||||
|
||||
export default function WebhookSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Settings | Webhooks`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Webhooks`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Configure webhooks to receive notifications when cards are created, updated, moved, or deleted.`}
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="primary" onClick={() => openModal("NEW_WEBHOOK")}>
|
||||
{t`Add webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WebhookList workspacePublicId={workspace.publicId} />
|
||||
</div>
|
||||
|
||||
{/* Webhook-specific modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "EDIT_WEBHOOK"}
|
||||
>
|
||||
<NewWebhookModal workspacePublicId={workspace.publicId} isEdit />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WEBHOOK"}
|
||||
>
|
||||
<DeleteWebhookConfirmation workspacePublicId={workspace.publicId} />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescript
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
@@ -86,6 +87,15 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Week start day`}
|
||||
</h2>
|
||||
<UpdateWeekStartDayForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
weekStartDay={workspaceData?.weekStartDay ?? 1}
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Email visibility`}
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface DeleteWebhookConfirmationProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`Delete webhook`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Are you sure you want to delete the webhook "${webhookName}"? This action cannot be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
isLoading={deleteWebhookMutation.isPending}
|
||||
>
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal file
322
apps/web/src/views/settings/components/NewWebhookModal.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const newWebhookSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook name is required` })
|
||||
.max(255, { message: t`Webhook name cannot exceed 255 characters` }),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, { message: t`Webhook URL is required` })
|
||||
.url({ message: t`Please enter a valid URL` })
|
||||
.max(2048, { message: t`URL cannot exceed 2048 characters` }),
|
||||
secret: z
|
||||
.string()
|
||||
.max(512, { message: t`Secret cannot exceed 512 characters` })
|
||||
.optional(),
|
||||
events: z
|
||||
.array(z.enum(webhookEvents))
|
||||
.min(1, { message: t`Select at least one event` }),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
type WebhookFormData = z.infer<typeof newWebhookSchema>;
|
||||
|
||||
interface NewWebhookModalProps {
|
||||
workspacePublicId: string;
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
const modalState = isEdit ? getModalState("EDIT_WEBHOOK") : null;
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<WebhookFormData>({
|
||||
resolver: zodResolver(newWebhookSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && webhookPublicId && modalState) {
|
||||
reset({
|
||||
name: modalState.name ?? "",
|
||||
url: modalState.url ?? "",
|
||||
secret: "",
|
||||
events: modalState.events ?? ["card.created"],
|
||||
active: modalState.active ?? true,
|
||||
});
|
||||
} else if (!isEdit) {
|
||||
reset({
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [...webhookEvents],
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
}, [isEdit, webhookPublicId, modalState, reset]);
|
||||
|
||||
// Clear modal state when closing
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isEdit) {
|
||||
clearModalState("EDIT_WEBHOOK");
|
||||
}
|
||||
};
|
||||
}, [isEdit, clearModalState]);
|
||||
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: WebhookFormData) => {
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
active: data.active,
|
||||
});
|
||||
} else {
|
||||
createWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
events: data.events,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestWebhook = () => {
|
||||
if (!webhookPublicId) return;
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">
|
||||
{isEdit ? t`Edit webhook` : t`New webhook`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Name`}
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`My webhook`}
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`URL`}
|
||||
</label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
{...register("url")}
|
||||
errorMessage={errors.url?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Secret (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-dark-800">
|
||||
{t`Used to sign webhook payloads for verification. Leave blank to keep existing secret.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Events`}
|
||||
</label>
|
||||
<Controller
|
||||
name="events"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="space-y-2">
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value.includes(event)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter((v) => v !== event)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("active")}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
{isEdit && webhookPublicId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleTestWebhook}
|
||||
isLoading={isTestingWebhook}
|
||||
>
|
||||
{t`Send test`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{isEdit ? t`Save changes` : t`Create webhook`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function UpdateWeekStartDayForm({
|
||||
workspacePublicId,
|
||||
weekStartDay,
|
||||
disabled = false,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
weekStartDay: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [value, setValue] = useState(weekStartDay);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(weekStartDay);
|
||||
}, [weekStartDay]);
|
||||
|
||||
const updateWorkspace = api.workspace.update.useMutation({
|
||||
onSuccess: () => {
|
||||
if (workspacePublicId && workspacePublicId.length >= 12) {
|
||||
void utils.workspace.byId.invalidate({
|
||||
workspacePublicId,
|
||||
});
|
||||
void utils.workspace.all.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (disabled) return;
|
||||
const newValue = Number(e.target.value);
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled || updateWorkspace.isPending}
|
||||
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
>
|
||||
<option value={0}>{t`Sunday`}</option>
|
||||
<option value={1}>{t`Monday`}</option>
|
||||
<option value={6}>{t`Saturday`}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal file
295
apps/web/src/views/settings/components/WebhookList.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { format } from "date-fns";
|
||||
import { HiEllipsisHorizontal } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useLocalisation } from "~/hooks/useLocalisation";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface TableRowProps {
|
||||
publicId?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
events?: string[];
|
||||
active?: boolean;
|
||||
createdAt?: Date | null;
|
||||
dateLocale?: DateFnsLocale;
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
onEdit?: () => void;
|
||||
onTest?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function formatEvents(events: string[]) {
|
||||
return events
|
||||
.map((e) => e.replace("card.", ""))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatDate(date?: Date | null, locale?: DateFnsLocale) {
|
||||
if (!date) return "Never";
|
||||
return format(date, "MMM d, yyyy", { locale });
|
||||
}
|
||||
|
||||
function TableRow({
|
||||
publicId,
|
||||
name,
|
||||
url,
|
||||
events,
|
||||
active,
|
||||
createdAt,
|
||||
dateLocale,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
onEdit,
|
||||
onTest,
|
||||
onDelete,
|
||||
}: TableRowProps) {
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[25%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<div className="ml-2 min-w-0 flex-1">
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[30%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"truncate text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[180px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[20%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[100px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{events && formatEvents(events)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-medium ring-1 ring-inset",
|
||||
active
|
||||
? "bg-emerald-500/10 text-emerald-400 ring-emerald-500/20"
|
||||
: "bg-gray-500/10 text-gray-400 ring-gray-500/20",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (active ? t`Active` : t`Inactive`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="w-[10%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{formatDate(createdAt, dateLocale)}
|
||||
</p>
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[5%] min-w-[50px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
{!showSkeleton && (
|
||||
<div className="flex w-full items-center justify-center px-3">
|
||||
<div className="relative z-50">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Edit`,
|
||||
action: () => onEdit?.(),
|
||||
},
|
||||
{
|
||||
label: t`Test`,
|
||||
action: () => onTest?.(),
|
||||
},
|
||||
{
|
||||
label: t`Delete`,
|
||||
action: () => onDelete?.(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface WebhookListProps {
|
||||
workspacePublicId: string;
|
||||
}
|
||||
|
||||
export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const { openModal, setModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { dateLocale } = useLocalisation();
|
||||
|
||||
const { data: webhooks, isLoading } = api.webhook.list.useQuery({
|
||||
workspacePublicId,
|
||||
});
|
||||
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!isLoading && (!webhooks || webhooks.length === 0)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-light-300 bg-light-50 p-8 text-center dark:border-dark-300 dark:bg-dark-100">
|
||||
<p className="text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`No webhooks configured. Add a webhook to receive notifications.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<div className="inline-block min-w-full py-2 pb-12 align-middle">
|
||||
<div className="relative h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-[700px] divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[25%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
>
|
||||
{t`Name`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[30%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`URL`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Events`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Status`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[10%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Created`}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{/* Actions column */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
webhooks?.map((webhook, index) => (
|
||||
<TableRow
|
||||
key={webhook.publicId}
|
||||
publicId={webhook.publicId}
|
||||
name={webhook.name}
|
||||
url={webhook.url}
|
||||
events={webhook.events}
|
||||
active={webhook.active}
|
||||
createdAt={webhook.createdAt}
|
||||
dateLocale={dateLocale}
|
||||
isLastRow={index === webhooks.length - 1}
|
||||
onEdit={() => {
|
||||
setModalState("EDIT_WEBHOOK", {
|
||||
publicId: webhook.publicId,
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
});
|
||||
openModal("EDIT_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
onTest={() => {
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhook.publicId,
|
||||
});
|
||||
}}
|
||||
onDelete={() => {
|
||||
openModal("DELETE_WEBHOOK", webhook.publicId, webhook.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton isLastRow />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,9 @@ 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}
|
||||
@@ -66,6 +69,7 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
|
||||
@@ -37,6 +37,9 @@ 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}
|
||||
|
||||
@@ -57,6 +60,7 @@ services:
|
||||
- S3_REGION=${S3_REGION}
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
|
||||
75
packages/api/integration-tests/test-db.ts
Normal file
75
packages/api/integration-tests/test-db.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
||||
import { drizzle } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import * as schema from "@kan/db/schema";
|
||||
|
||||
export type TestDbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a fresh in-memory PGlite database for testing.
|
||||
* Each call returns an isolated database instance with migrations applied.
|
||||
*/
|
||||
export async function createTestDb(): Promise<TestDbClient> {
|
||||
const client = new PGlite({
|
||||
extensions: { uuid_ossp, pg_trgm },
|
||||
});
|
||||
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
// Run migrations
|
||||
await migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
return db as unknown as TestDbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a test database with a workspace and user for testing.
|
||||
* Returns the created entities for use in tests.
|
||||
*/
|
||||
export async function seedTestData(db: TestDbClient) {
|
||||
// Create a test user
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create a test workspace (publicId must be exactly 12 chars)
|
||||
const [workspace] = await db
|
||||
.insert(schema.workspaces)
|
||||
.values({
|
||||
publicId: "wstest123456",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
ownerId: user!.id,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Add user as admin member of workspace
|
||||
await db.insert(schema.workspaceMembers).values({
|
||||
publicId: "wm1234567890",
|
||||
email: user!.email,
|
||||
workspaceId: workspace!.id,
|
||||
userId: user!.id,
|
||||
createdBy: user!.id,
|
||||
role: "admin",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
return { user: user!, workspace: workspace! };
|
||||
}
|
||||
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
242
packages/api/integration-tests/webhook.integration.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import { createTestDb, seedTestData, type TestDbClient } from "./test-db";
|
||||
|
||||
describe("webhook repository integration tests", () => {
|
||||
let db: TestDbClient;
|
||||
let testUser: { id: string; name: string | null };
|
||||
let testWorkspace: { id: number; publicId: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb();
|
||||
const seeded = await seedTestData(db);
|
||||
testUser = seeded.user;
|
||||
testWorkspace = seeded.workspace;
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with all fields", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "my-secret",
|
||||
events: ["card.created", "card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("My Webhook");
|
||||
expect(webhook!.url).toBe("https://example.com/webhook");
|
||||
expect(webhook!.events).toEqual(["card.created", "card.updated"]);
|
||||
expect(webhook!.active).toBe(true);
|
||||
expect(webhook!.publicId).toMatch(/^[a-zA-Z0-9]{12}$/);
|
||||
});
|
||||
|
||||
it("creates a webhook without secret", async () => {
|
||||
const webhook = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "No Secret Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.deleted"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(webhook).not.toBeNull();
|
||||
expect(webhook!.name).toBe("No Secret Webhook");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getByPublicId", () => {
|
||||
it("retrieves a webhook by public ID", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.publicId).toBe(created!.publicId);
|
||||
expect(retrieved!.name).toBe("Test Webhook");
|
||||
});
|
||||
|
||||
it("returns null for non-existent public ID", async () => {
|
||||
const retrieved = await webhookRepo.getByPublicId(db, "nonexistent12");
|
||||
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllByWorkspaceId", () => {
|
||||
it("returns all webhooks for a workspace", async () => {
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 1",
|
||||
url: "https://example.com/webhook1",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Webhook 2",
|
||||
url: "https://example.com/webhook2",
|
||||
events: ["card.updated"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toHaveLength(2);
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 1");
|
||||
expect(webhooks.map((w) => w.name)).toContain("Webhook 2");
|
||||
});
|
||||
|
||||
it("returns empty array for workspace with no webhooks", async () => {
|
||||
const webhooks = await webhookRepo.getAllByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(webhooks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveByWorkspaceId", () => {
|
||||
it("returns only active webhooks", async () => {
|
||||
const active = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Active Webhook",
|
||||
url: "https://example.com/active",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const inactive = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Inactive Webhook",
|
||||
url: "https://example.com/inactive",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// Deactivate one webhook
|
||||
await webhookRepo.update(db, inactive!.publicId, { active: false });
|
||||
|
||||
const activeWebhooks = await webhookRepo.getActiveByWorkspaceId(db, testWorkspace.id);
|
||||
|
||||
expect(activeWebhooks).toHaveLength(1);
|
||||
// getActiveByWorkspaceId returns only publicId, url, secret, events
|
||||
expect(activeWebhooks[0]!.url).toBe("https://example.com/active");
|
||||
expect(activeWebhooks[0]!.publicId).toBe(active!.publicId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Original Name",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.name).toBe("Updated Name");
|
||||
expect(updated!.url).toBe("https://example.com/webhook"); // Unchanged
|
||||
});
|
||||
|
||||
it("updates webhook events", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
events: ["card.created", "card.updated", "card.deleted"],
|
||||
});
|
||||
|
||||
expect(updated!.events).toEqual(["card.created", "card.updated", "card.deleted"]);
|
||||
});
|
||||
|
||||
it("updates webhook active status", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
expect(created!.active).toBe(true);
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(updated!.active).toBe(false);
|
||||
});
|
||||
|
||||
it("sets updatedAt timestamp on update", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
// create() doesn't return updatedAt, verify via getByPublicId
|
||||
const initial = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(initial!.updatedAt).toBeNull();
|
||||
|
||||
const updated = await webhookRepo.update(db, created!.publicId, {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated!.updatedAt).not.toBeNull();
|
||||
expect(updated!.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null for non-existent webhook", async () => {
|
||||
const updated = await webhookRepo.update(db, "nonexistent12", {
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hardDelete", () => {
|
||||
it("deletes a webhook permanently", async () => {
|
||||
const created = await webhookRepo.create(db, {
|
||||
workspaceId: testWorkspace.id,
|
||||
name: "To Be Deleted",
|
||||
url: "https://example.com/webhook",
|
||||
events: ["card.created"],
|
||||
createdBy: testUser.id,
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(db, created!.publicId);
|
||||
|
||||
const retrieved = await webhookRepo.getByPublicId(db, created!.publicId);
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw for non-existent webhook", async () => {
|
||||
await expect(
|
||||
webhookRepo.hardDelete(db, "nonexistent12"),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,15 @@
|
||||
"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:",
|
||||
@@ -60,7 +63,8 @@
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { listRouter } from "./routers/list";
|
||||
import { memberRouter } from "./routers/member";
|
||||
import { permissionRouter } from "./routers/permission";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { webhookRouter } from "./routers/webhook";
|
||||
import { workspaceRouter } from "./routers/workspace";
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
import: importRouter,
|
||||
permission: permissionRouter,
|
||||
user: userRouter,
|
||||
webhook: webhookRouter,
|
||||
workspace: workspaceRouter,
|
||||
integration: integrationRouter,
|
||||
});
|
||||
|
||||
423
packages/api/src/routers/webhook.test.ts
Normal file
423
packages/api/src/routers/webhook.test.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getAllByWorkspaceId: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
hardDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/workspace.repo", () => ({
|
||||
getByPublicId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/permissions", () => ({
|
||||
assertPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
|
||||
const mockGetAllByWorkspaceId = webhookRepo.getAllByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
const mockGetByPublicId = webhookRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = webhookRepo.create as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = webhookRepo.update as ReturnType<typeof vi.fn>;
|
||||
const mockHardDelete = webhookRepo.hardDelete as ReturnType<typeof vi.fn>;
|
||||
const mockWorkspaceGetByPublicId = workspaceRepo.getByPublicId as ReturnType<typeof vi.fn>;
|
||||
const mockAssertPermission = assertPermission as ReturnType<typeof vi.fn>;
|
||||
|
||||
// We need to import the router after mocks are set up
|
||||
// Testing approach: call the internal handler logic through a test wrapper
|
||||
describe("webhook router", () => {
|
||||
const mockDb = {} as never;
|
||||
const mockUser = { id: "user-123", name: "Test User", email: "test@example.com" };
|
||||
const mockWorkspace = { id: 1, publicId: "ws-123456789" };
|
||||
const mockWebhook = {
|
||||
id: 1,
|
||||
publicId: "wh-123456789",
|
||||
workspaceId: 1,
|
||||
name: "My Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
secret: "secret123",
|
||||
events: ["card.created", "card.updated"] as const,
|
||||
active: true,
|
||||
createdAt: new Date("2024-01-15"),
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertPermission.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("authorization", () => {
|
||||
it("throws UNAUTHORIZED when user is not authenticated", async () => {
|
||||
// Import fresh to get mocked version
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const ctx = {
|
||||
user: null,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when workspace does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-nonexistent" }),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("checks workspace:manage permission via assertPermission", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).list({ workspacePublicId: "ws-123456789" });
|
||||
|
||||
expect(mockAssertPermission).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
"workspace:manage",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("returns all webhooks for workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([mockWebhook]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe("My Webhook");
|
||||
expect(mockGetAllByWorkspaceId).toHaveBeenCalledWith(mockDb, mockWorkspace.id);
|
||||
});
|
||||
|
||||
it("returns empty array when no webhooks exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetAllByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).list({
|
||||
workspacePublicId: "ws-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a webhook with valid input", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(result.name).toBe("New Webhook");
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, {
|
||||
workspaceId: mockWorkspace.id,
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
secret: undefined,
|
||||
events: ["card.created"],
|
||||
createdBy: mockUser.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a webhook with secret", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const newWebhook = {
|
||||
publicId: "wh-new123456",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
events: ["card.created"] as const,
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(newWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "Secure Webhook",
|
||||
url: "https://example.com/secure",
|
||||
secret: "my-secret-key",
|
||||
events: ["card.created"],
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(mockDb, expect.objectContaining({
|
||||
secret: "my-secret-key",
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws INTERNAL_SERVER_ERROR when create fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockCreate.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).create({
|
||||
workspacePublicId: "ws-123456789",
|
||||
name: "New Webhook",
|
||||
url: "https://example.com/new",
|
||||
events: ["card.created"],
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("updates webhook name", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const updatedWebhook = { ...mockWebhook, name: "Updated Name" };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockUpdate.mockResolvedValueOnce(updatedWebhook);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(mockUpdate).toHaveBeenCalledWith(mockDb, "wh-123456789", {
|
||||
name: "Updated Name",
|
||||
url: undefined,
|
||||
secret: undefined,
|
||||
events: undefined,
|
||||
active: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook belongs to different workspace", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
const webhookFromDifferentWorkspace = { ...mockWebhook, workspaceId: 999 };
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(webhookFromDifferentWorkspace);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).update({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
name: "Updated Name",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("deletes webhook successfully", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
mockHardDelete.mockResolvedValueOnce(undefined);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockHardDelete).toHaveBeenCalledWith(mockDb, "wh-123456789");
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when webhook does not exist", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(null);
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
await expect(
|
||||
webhookRouter.createCaller(ctx).delete({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-nonexistent",
|
||||
}),
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("test", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("sends test payload to webhook URL", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
mockWebhook.url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when test fails", async () => {
|
||||
const { webhookRouter } = await import("./webhook");
|
||||
|
||||
mockWorkspaceGetByPublicId.mockResolvedValueOnce(mockWorkspace);
|
||||
mockGetByPublicId.mockResolvedValueOnce(mockWebhook);
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
user: mockUser,
|
||||
db: mockDb,
|
||||
} as never;
|
||||
|
||||
const result = await webhookRouter.createCaller(ctx).test({
|
||||
workspacePublicId: "ws-123456789",
|
||||
webhookPublicId: "wh-123456789",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.error).toContain("500");
|
||||
});
|
||||
});
|
||||
});
|
||||
359
packages/api/src/routers/webhook.ts
Normal file
359
packages/api/src/routers/webhook.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { webhookEvents } from "@kan/db/schema";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import {
|
||||
webhookUrlSchema,
|
||||
sendWebhookToUrl,
|
||||
createCardWebhookPayload,
|
||||
} from "../utils/webhook";
|
||||
|
||||
const webhookEventSchema = z.enum(webhookEvents);
|
||||
|
||||
export const webhookRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get all webhooks for a workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Retrieves all webhooks configured for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
return webhookRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks",
|
||||
description: "Creates a new webhook for a workspace",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255),
|
||||
url: webhookUrlSchema,
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const result = await webhookRepo.create(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to create webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a webhook",
|
||||
method: "PUT",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Updates a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
url: webhookUrlSchema.optional(),
|
||||
secret: z.string().max(512).optional(),
|
||||
events: z.array(webhookEventSchema).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
events: z.array(webhookEventSchema),
|
||||
active: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await webhookRepo.update(ctx.db, input.webhookPublicId, {
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
active: input.active,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: "Unable to update webhook",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a webhook",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}",
|
||||
description: "Deletes a webhook by its public ID",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await webhookRepo.hardDelete(ctx.db, input.webhookPublicId);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
test: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Test a webhook",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/webhooks/{webhookPublicId}/test",
|
||||
description: "Sends a test payload to a webhook",
|
||||
tags: ["Webhooks"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
webhookPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
statusCode: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: "User not authenticated",
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: "Workspace not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(ctx.db, userId, workspace.id, "workspace:manage");
|
||||
|
||||
const webhook = await webhookRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.webhookPublicId,
|
||||
);
|
||||
|
||||
if (!webhook || webhook.workspaceId !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: "Webhook not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const testPayload = createCardWebhookPayload("card.created", {
|
||||
id: "test-card-id",
|
||||
title: "Test Card",
|
||||
description: "This is a test webhook payload",
|
||||
dueDate: null,
|
||||
listId: "test-list-id",
|
||||
}, {
|
||||
boardId: "test-board-id",
|
||||
boardName: "Test Board",
|
||||
listName: "Test List",
|
||||
user: {
|
||||
id: userId,
|
||||
name: ctx.user?.name ?? "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
webhook.url,
|
||||
webhook.secret ?? undefined,
|
||||
testPayload,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -4,11 +4,10 @@ import { z } from "zod";
|
||||
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { generateAvatarUrl, generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { generateAvatarUrl } from "@kan/shared/utils";
|
||||
|
||||
export const workspaceRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
@@ -84,8 +83,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const isAdmin = userMember?.role === "admin";
|
||||
|
||||
// Show emails if user is admin OR workspace setting allows it
|
||||
const shouldShowEmails =
|
||||
isAdmin || result.showEmailsToMembers === true;
|
||||
const shouldShowEmails = isAdmin || result.showEmailsToMembers === true;
|
||||
|
||||
// Generate presigned URLs for member avatars
|
||||
const membersWithAvatarUrls = await Promise.all(
|
||||
@@ -295,6 +293,9 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
description: z.string().min(3).max(280).optional(),
|
||||
showEmailsToMembers: z.boolean().optional(),
|
||||
weekStartDay: z
|
||||
.union([z.literal(0), z.literal(1), z.literal(6)])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
|
||||
@@ -356,6 +357,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
showEmailsToMembers: input.showEmailsToMembers,
|
||||
weekStartDay: input.weekStartDay,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ 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;
|
||||
@@ -90,7 +93,7 @@ export const createRESTContext = async ({ req }: CreateNextContextOptions) => {
|
||||
try {
|
||||
session = await auth.api.getSession();
|
||||
} catch (error) {
|
||||
console.error("Error getting session, ", error);
|
||||
log.error({ err: error }, "Error getting session");
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -118,7 +121,23 @@ export const createTRPCRouter = t.router;
|
||||
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
export const publicProcedure = t.procedure.meta({
|
||||
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.use(loggingMiddleware).meta({
|
||||
openapi: { method: "GET", path: "/public" },
|
||||
});
|
||||
|
||||
@@ -142,14 +161,18 @@ const enforceUserIsAdmin = t.middleware(async ({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed).meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
export const protectedProcedure = t.procedure
|
||||
.use(loggingMiddleware)
|
||||
.use(enforceUserIsAuthed)
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/protected",
|
||||
},
|
||||
});
|
||||
|
||||
export const adminProtectedProcedure = t.procedure
|
||||
.use(loggingMiddleware)
|
||||
.use(enforceUserIsAdmin)
|
||||
.meta({
|
||||
openapi: {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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";
|
||||
@@ -72,6 +75,7 @@ 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) => {
|
||||
@@ -91,6 +95,7 @@ export async function sendMentionEmails({
|
||||
|
||||
// If notification already exists, skip sending email
|
||||
if (notificationExists) {
|
||||
log.debug({ email, cardPublicId }, "Skipping duplicate mention email");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,20 +119,14 @@ export async function sendMentionEmails({
|
||||
cardUrl,
|
||||
},
|
||||
);
|
||||
log.info({ email, cardPublicId }, "Mention email sent");
|
||||
} catch (error) {
|
||||
console.error("Failed to send mention email:", {
|
||||
email,
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
log.error({ err: error, email, cardPublicId }, "Failed to send mention email");
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending mention emails:", {
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
log.error({ err: error, cardPublicId }, "Error sending mention emails");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ 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;
|
||||
@@ -45,7 +48,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
|
||||
|
||||
// Use Redis if available, otherwise fall back to in-memory storage
|
||||
if (redis) {
|
||||
console.log("Using Redis for rate limiting");
|
||||
log.debug("Using Redis for rate limiting");
|
||||
return new RateLimiterRedis({
|
||||
storeClient: redis,
|
||||
points,
|
||||
@@ -53,7 +56,7 @@ function createRateLimiter(options: RateLimitOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Using in-memory for rate limiting");
|
||||
log.debug("Redis unavailable, falling back to in-memory rate limiting");
|
||||
return new RateLimiterMemory({
|
||||
points,
|
||||
duration,
|
||||
|
||||
@@ -404,11 +404,10 @@ describe("webhook utilities", () => {
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
// getActiveByWorkspaceId fetches all active webhooks; event filtering is client-side
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
@@ -421,8 +420,8 @@ describe("webhook utilities", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
it("does not send when no webhooks match the event (client-side filtering)", async () => {
|
||||
// Returns webhooks that don't match the event — client-side filter excludes them
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
@@ -430,7 +429,6 @@ describe("webhook utilities", () => {
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ 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;
|
||||
|
||||
@@ -185,12 +188,13 @@ export async function sendWebhooksForWorkspace(
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get active webhooks for this workspace
|
||||
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
|
||||
|
||||
// Filter webhooks that are subscribed to this specific event
|
||||
const webhooksForEvent = webhooks.filter((webhook) =>
|
||||
webhook.events.includes(payload.event),
|
||||
// Get active webhooks for this workspace and filter by event client-side
|
||||
const allWebhooks = await webhookRepo.getActiveByWorkspaceId(
|
||||
db,
|
||||
workspaceId,
|
||||
);
|
||||
const webhooksForEvent = allWebhooks.filter((w) =>
|
||||
w.events.includes(payload.event),
|
||||
);
|
||||
|
||||
// Send to all subscribed webhooks in parallel (fire and forget)
|
||||
@@ -198,9 +202,9 @@ export async function sendWebhooksForWorkspace(
|
||||
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
|
||||
(result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
|
||||
);
|
||||
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");
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -209,7 +213,7 @@ export async function sendWebhooksForWorkspace(
|
||||
// Wait for all to complete but don't block on failures
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error("Failed to send webhooks for workspace:", error);
|
||||
log.error({ err: error, workspaceId }, "Failed to send webhooks for workspace");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
packages/api/vitest.config.ts
Normal file
13
packages/api/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "integration-tests/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@kan/db": resolve(__dirname, "../db/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -39,6 +39,7 @@
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "^1.4.6",
|
||||
"@kan/logger": "workspace:^",
|
||||
"better-auth": "^1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ 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 = {
|
||||
@@ -103,6 +106,7 @@ 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,
|
||||
@@ -122,6 +126,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
},
|
||||
workflowId: "user-signup",
|
||||
});
|
||||
log.info({ workflowId: "user-signup", userId: user.id }, "Novu workflow triggered");
|
||||
|
||||
await notificationClient.subscribers.credentials.update(
|
||||
{
|
||||
@@ -134,7 +139,7 @@ export function createDatabaseHooks(db: dbClient) {
|
||||
user.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error adding user to notification client", error);
|
||||
log.error({ err: error }, "Error adding user to notification client");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,10 @@ 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";
|
||||
@@ -101,9 +104,7 @@ export function createPlugins(db: dbClient) {
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
log.info({ subscriptionId: stripeSubscription.id }, "Pro subscription activated with unlimited seats");
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
@@ -175,11 +176,7 @@ export function createPlugins(db: dbClient) {
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
log.info({ email, isInvite: decodedUrl.includes("type=invite") }, "Sending magic link");
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
@@ -211,7 +208,7 @@ export function createPlugins(db: dbClient) {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
log.error({ err: error }, "Failed to fetch invite details");
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
@@ -239,11 +236,7 @@ export function createPlugins(db: dbClient) {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending magic link:", {
|
||||
email,
|
||||
url,
|
||||
error,
|
||||
});
|
||||
log.error({ err: error, email }, "Error sending magic link");
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -273,7 +266,7 @@ export function createPlugins(db: dbClient) {
|
||||
picture?: string;
|
||||
avatar?: string;
|
||||
}) => {
|
||||
console.log("OIDC profile:", profile);
|
||||
log.debug({ profile }, "OIDC profile received");
|
||||
|
||||
const name =
|
||||
profile.name ??
|
||||
|
||||
@@ -4,8 +4,11 @@ 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) {
|
||||
@@ -32,6 +35,7 @@ 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,
|
||||
@@ -43,7 +47,8 @@ export async function triggerWorkflow(
|
||||
},
|
||||
workflowId,
|
||||
});
|
||||
log.info({ workflowId, userId: user.id }, "Novu workflow triggered");
|
||||
} catch (error) {
|
||||
console.error("Error triggering workflow", error);
|
||||
log.error({ err: error, workflowId }, "Error triggering workflow");
|
||||
}
|
||||
}
|
||||
|
||||
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal file
52
packages/db/migrations/20260311065722_AddWeekStartDay.sql
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Migrations and snapshots seem to have become out of sync (this should fix that)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = 'source'
|
||||
AND e.enumlabel = 'github'
|
||||
) THEN
|
||||
ALTER TYPE "public"."source" ADD VALUE 'github';
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"workspaceId" bigint NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"secret" text,
|
||||
"events" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"createdBy" uuid NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "integration" ALTER COLUMN "accessToken" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "card_activity" ADD COLUMN IF NOT EXISTS "attachmentId" bigint;--> statement-breakpoint
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace" ADD COLUMN IF NOT EXISTS "weekStartDay" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dc863226-583e-4985-8b6a-4c360a3b9fa1",
|
||||
"prevId": "4e1ebb8b-bd52-48c5-87d6-8e6e5eb8bbde",
|
||||
"prevId": "d0ce9209-d34a-4ae8-b93e-d73391bfec64",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
3868
packages/db/migrations/meta/20260311065722_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -206,11 +206,18 @@
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1771930355536,
|
||||
"tag": "20260224105235_AddGitHubIntegrationSupport",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1773212242728,
|
||||
"tag": "20260311065722_AddWeekStartDay",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "^0.3.7",
|
||||
"@kan/logger": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
|
||||
@@ -6,8 +6,12 @@ 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;
|
||||
};
|
||||
@@ -16,7 +20,7 @@ export const createDrizzleClient = (): dbClient => {
|
||||
const connectionString = process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
console.log("POSTGRES_URL environment variable is not set, using PGLite");
|
||||
log.warn("POSTGRES_URL not set, falling back to PGLite");
|
||||
|
||||
const client = new PGlite({
|
||||
dataDir: "./pgdata",
|
||||
|
||||
@@ -402,8 +402,6 @@ 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}`,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const update = async (
|
||||
plan?: "free" | "pro" | "enterprise";
|
||||
description?: string;
|
||||
showEmailsToMembers?: boolean;
|
||||
weekStartDay?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -137,6 +138,7 @@ export const update = async (
|
||||
plan: workspaceInput.plan,
|
||||
description: workspaceInput.description,
|
||||
showEmailsToMembers: workspaceInput.showEmailsToMembers,
|
||||
weekStartDay: workspaceInput.weekStartDay,
|
||||
})
|
||||
.where(eq(workspaces.publicId, workspacePublicId))
|
||||
.returning({
|
||||
@@ -147,6 +149,7 @@ export const update = async (
|
||||
description: workspaces.description,
|
||||
plan: workspaces.plan,
|
||||
showEmailsToMembers: workspaces.showEmailsToMembers,
|
||||
weekStartDay: workspaces.weekStartDay,
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -189,6 +192,7 @@ export const getByPublicIdWithMembers = (
|
||||
name: true,
|
||||
slug: true,
|
||||
showEmailsToMembers: true,
|
||||
weekStartDay: true,
|
||||
},
|
||||
with: {
|
||||
members: {
|
||||
@@ -274,6 +278,7 @@ export const getAllByUserId = async (db: dbClient, userId: string) => {
|
||||
description: true,
|
||||
slug: true,
|
||||
plan: true,
|
||||
weekStartDay: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
// https://github.com/drizzle-team/drizzle-orm/issues/2903
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
@@ -45,6 +46,7 @@ export const workspaces = pgTable("workspace", {
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
plan: workspacePlanEnum("plan").notNull().default("free"),
|
||||
showEmailsToMembers: boolean("showEmailsToMembers").notNull().default(true),
|
||||
weekStartDay: integer("weekStartDay").notNull().default(1),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kan/logger": "workspace:^",
|
||||
"@novu/api": "^3.11.0",
|
||||
"@react-email/components": "^1.0.1",
|
||||
"nodemailer": "^7.0.3",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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";
|
||||
@@ -44,6 +47,7 @@ export const sendEmail = async (
|
||||
template: Templates,
|
||||
data: Record<string, string>,
|
||||
) => {
|
||||
log.info({ to, subject, template }, "Sending email");
|
||||
try {
|
||||
const EmailTemplate = emailTemplates[template];
|
||||
|
||||
@@ -62,16 +66,10 @@ 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) {
|
||||
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,
|
||||
});
|
||||
log.error({ err: error, to, from: process.env.EMAIL_FROM, subject, template }, "Email sending failed");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
34
packages/logger/package.json
Normal file
34
packages/logger/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
20
packages/logger/src/index.ts
Normal file
20
packages/logger/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from "pino";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const level = process.env.LOG_LEVEL ?? (isDev ? "debug" : "info");
|
||||
|
||||
export const logger = pino({
|
||||
level,
|
||||
...(isDev && {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
ignore: "pid,hostname",
|
||||
translateTime: "HH:MM:ss",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export const createLogger = (module: string) => logger.child({ module });
|
||||
6
packages/logger/tsconfig.json
Normal file
6
packages/logger/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/internal-package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
338
pnpm-lock.yaml
generated
338
pnpm-lock.yaml
generated
@@ -103,6 +103,9 @@ importers:
|
||||
'@kan/db':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/db
|
||||
'@kan/logger':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/logger
|
||||
'@kan/shared':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shared
|
||||
@@ -311,6 +314,9 @@ importers:
|
||||
'@kan/email':
|
||||
specifier: workspace:^
|
||||
version: link:../email
|
||||
'@kan/logger':
|
||||
specifier: workspace:^
|
||||
version: link:../logger
|
||||
'@kan/shared':
|
||||
specifier: workspace:^
|
||||
version: link:../shared
|
||||
@@ -351,12 +357,18 @@ importers:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.2
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
packages/auth:
|
||||
dependencies:
|
||||
'@better-auth/stripe':
|
||||
specifier: ^1.4.6
|
||||
version: 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))(better-auth@1.4.6(next@16.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(stripe@18.5.0(@types/node@25.0.0))
|
||||
'@kan/logger':
|
||||
specifier: workspace:^
|
||||
version: link:../logger
|
||||
better-auth:
|
||||
specifier: ^1.4.6
|
||||
version: 1.4.6(next@16.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -397,6 +409,9 @@ importers:
|
||||
'@electric-sql/pglite':
|
||||
specifier: ^0.3.7
|
||||
version: 0.3.7
|
||||
'@kan/logger':
|
||||
specifier: workspace:^
|
||||
version: link:../logger
|
||||
'@kan/shared':
|
||||
specifier: workspace:^
|
||||
version: link:../shared
|
||||
@@ -449,6 +464,9 @@ importers:
|
||||
|
||||
packages/email:
|
||||
dependencies:
|
||||
'@kan/logger':
|
||||
specifier: workspace:^
|
||||
version: link:../logger
|
||||
'@novu/api':
|
||||
specifier: ^3.11.0
|
||||
version: 3.11.0
|
||||
@@ -484,6 +502,34 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.2
|
||||
|
||||
packages/logger:
|
||||
dependencies:
|
||||
pino:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0
|
||||
pino-pretty:
|
||||
specifier: ^13.1.3
|
||||
version: 13.1.3
|
||||
devDependencies:
|
||||
'@kan/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/eslint
|
||||
'@kan/prettier-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/prettier
|
||||
'@kan/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../tooling/typescript
|
||||
eslint:
|
||||
specifier: 'catalog:'
|
||||
version: 9.34.0(jiti@2.6.1)
|
||||
prettier:
|
||||
specifier: 'catalog:'
|
||||
version: 3.6.2
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.2
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
@@ -2973,6 +3019,9 @@ packages:
|
||||
'@octokit/types@9.3.2':
|
||||
resolution: {integrity: sha512-D4iHGTdAnEEVsB8fl95m1hiz7D5YiRdQ9b/OEb3BYRVwbLsGHcRVPz+u+BgRLNk0Q0/4iZCBqDN96j2XNxfXrA==}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -4465,6 +4514,10 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
atomically@2.1.0:
|
||||
resolution: {integrity: sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==}
|
||||
|
||||
@@ -4806,6 +4859,9 @@ packages:
|
||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||
engines: {node: '>=12.5.0'}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
colors@1.0.3:
|
||||
resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
@@ -4961,6 +5017,9 @@ packages:
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
dateformat@4.6.3:
|
||||
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4986,15 +5045,6 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -5267,6 +5317,9 @@ packages:
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
engine.io-parser@5.2.3:
|
||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -5530,6 +5583,9 @@ packages:
|
||||
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
fast-copy@4.0.2:
|
||||
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -5543,6 +5599,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
@@ -5815,6 +5874,9 @@ packages:
|
||||
header-case@1.0.1:
|
||||
resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==}
|
||||
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
@@ -6178,6 +6240,10 @@ packages:
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
joycon@3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
js-sha256@0.10.1:
|
||||
resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==}
|
||||
|
||||
@@ -6716,10 +6782,6 @@ packages:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
|
||||
minimatch@10.0.3:
|
||||
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@10.1.1:
|
||||
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -6949,6 +7011,10 @@ packages:
|
||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -7140,6 +7206,23 @@ packages:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||
|
||||
pino-pretty@13.1.3:
|
||||
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
|
||||
hasBin: true
|
||||
|
||||
pino-std-serializers@7.1.0:
|
||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||
|
||||
pino@9.14.0:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
hasBin: true
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -7311,6 +7394,9 @@ packages:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -7391,6 +7477,9 @@ packages:
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -7406,6 +7495,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
radix3@1.1.2:
|
||||
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
|
||||
|
||||
@@ -7520,6 +7612,10 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
rechoir@0.6.2:
|
||||
resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -7691,6 +7787,10 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
@@ -7708,6 +7808,9 @@ packages:
|
||||
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
selderee@0.11.0:
|
||||
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
|
||||
|
||||
@@ -7840,6 +7943,9 @@ packages:
|
||||
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7963,6 +8069,10 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@5.0.3:
|
||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
@@ -8095,6 +8205,9 @@ packages:
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
@@ -10480,7 +10593,7 @@ snapshots:
|
||||
'@eslint/config-array@0.21.0':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10494,7 +10607,7 @@ snapshots:
|
||||
'@eslint/eslintrc@3.3.1':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
espree: 10.4.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
@@ -10997,7 +11110,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@apidevtools/swagger-parser': 10.1.1(openapi-types@12.1.3)
|
||||
'@mintlify/prebuild': 1.0.33(@apidevtools/swagger-parser@10.1.1(openapi-types@12.1.3))(@mintlify/models@0.0.29(openapi-types@12.1.3))(@mintlify/validation@0.1.63(@mintlify/models@0.0.29(openapi-types@12.1.3))(openapi-types@12.1.3))(fs-extra@11.3.1)(gray-matter@4.0.3)(openapi-types@12.1.3)(unist-util-visit@4.1.2)
|
||||
chalk: 5.6.0
|
||||
chalk: 5.6.2
|
||||
fs-extra: 11.3.1
|
||||
is-absolute-url: 4.0.1
|
||||
openapi-types: 12.1.3
|
||||
@@ -11034,7 +11147,7 @@ snapshots:
|
||||
'@mintlify/validation': 0.1.63(@mintlify/models@0.0.29(openapi-types@12.1.3))(openapi-types@12.1.3)
|
||||
'@octokit/rest': 19.0.13
|
||||
axios: 1.11.0
|
||||
chalk: 5.6.0
|
||||
chalk: 5.6.2
|
||||
chokidar: 3.6.0
|
||||
fs-extra: 11.3.1
|
||||
gray-matter: 4.0.3
|
||||
@@ -11223,6 +11336,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 18.1.1
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -12681,6 +12796,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
tinyrainbow: 2.0.0
|
||||
@@ -12966,6 +13089,8 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
atomically@2.1.0:
|
||||
dependencies:
|
||||
stubborn-fs: 2.0.0
|
||||
@@ -13330,6 +13455,8 @@ snapshots:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
colors@1.0.3: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
@@ -13477,6 +13604,8 @@ snapshots:
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
dateformat@4.6.3: {}
|
||||
|
||||
debounce-fn@6.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
@@ -13491,10 +13620,6 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -13674,6 +13799,10 @@ snapshots:
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
|
||||
engine.io-parser@5.2.3: {}
|
||||
|
||||
engine.io@6.6.4:
|
||||
@@ -14101,7 +14230,7 @@ snapshots:
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
@@ -14143,7 +14272,7 @@ snapshots:
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
@@ -14232,6 +14361,8 @@ snapshots:
|
||||
iconv-lite: 0.4.24
|
||||
tmp: 0.0.33
|
||||
|
||||
fast-copy@4.0.2: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -14246,6 +14377,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
@@ -14437,7 +14570,7 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 4.1.1
|
||||
minimatch: 10.0.3
|
||||
minimatch: 10.1.1
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 2.0.0
|
||||
@@ -14570,6 +14703,8 @@ snapshots:
|
||||
no-case: 2.3.2
|
||||
upper-case: 1.1.3
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
@@ -14960,6 +15095,8 @@ snapshots:
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
|
||||
js-sha256@0.10.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -15088,7 +15225,7 @@ snapshots:
|
||||
|
||||
log-symbols@5.1.0:
|
||||
dependencies:
|
||||
chalk: 5.6.0
|
||||
chalk: 5.6.2
|
||||
is-unicode-supported: 1.3.0
|
||||
|
||||
log-symbols@6.0.0:
|
||||
@@ -15843,10 +15980,6 @@ snapshots:
|
||||
|
||||
mini-svg-data-uri@1.4.4: {}
|
||||
|
||||
minimatch@10.0.3:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.0
|
||||
|
||||
minimatch@10.1.1:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.0
|
||||
@@ -16073,6 +16206,8 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -16131,7 +16266,7 @@ snapshots:
|
||||
|
||||
ora@6.3.1:
|
||||
dependencies:
|
||||
chalk: 5.6.0
|
||||
chalk: 5.6.2
|
||||
cli-cursor: 4.0.0
|
||||
cli-spinners: 2.9.2
|
||||
is-interactive: 2.0.0
|
||||
@@ -16310,6 +16445,46 @@ snapshots:
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-abstract-transport@3.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-pretty@13.1.3:
|
||||
dependencies:
|
||||
colorette: 2.0.20
|
||||
dateformat: 4.6.3
|
||||
fast-copy: 4.0.2
|
||||
fast-safe-stringify: 2.1.1
|
||||
help-me: 5.0.0
|
||||
joycon: 3.1.1
|
||||
minimist: 1.2.8
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pump: 3.0.4
|
||||
secure-json-parse: 4.1.0
|
||||
sonic-boom: 4.2.1
|
||||
strip-json-comments: 5.0.3
|
||||
|
||||
pino-std-serializers@7.1.0: {}
|
||||
|
||||
pino@9.14.0:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.1
|
||||
thread-stream: 3.1.0
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkg-types@2.3.0:
|
||||
@@ -16417,6 +16592,8 @@ snapshots:
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -16552,6 +16729,11 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 10.0.1
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
@@ -16562,6 +16744,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
radix3@1.1.2: {}
|
||||
|
||||
raf-schd@4.0.3: {}
|
||||
@@ -16715,6 +16899,8 @@ snapshots:
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
rechoir@0.6.2:
|
||||
dependencies:
|
||||
resolve: 1.22.10
|
||||
@@ -16968,6 +17154,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sax@1.2.1: {}
|
||||
@@ -16988,6 +17176,8 @@ snapshots:
|
||||
extend-shallow: 2.0.1
|
||||
kind-of: 6.0.3
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
selderee@0.11.0:
|
||||
dependencies:
|
||||
parseley: 0.12.1
|
||||
@@ -17201,6 +17391,10 @@ snapshots:
|
||||
ip-address: 10.0.1
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sonic-boom@4.2.1:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -17339,6 +17533,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
strip-literal@3.1.0:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
@@ -17509,6 +17705,10 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
@@ -17944,6 +18144,27 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.3.1(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
@@ -17959,6 +18180,21 @@ snapshots:
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vite@7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.56.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
terser: 5.44.1
|
||||
yaml: 2.8.1
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.11)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
@@ -18001,6 +18237,48 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@25.0.0)(jiti@2.6.1)(terser@5.44.1)(yaml@2.8.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 25.0.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
watchpack@2.4.4:
|
||||
|
||||
@@ -123,12 +123,14 @@
|
||||
"S3_SECRET_ACCESS_KEY",
|
||||
"S3_ENDPOINT",
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
"S3_AVATAR_UPLOAD_LIMIT",
|
||||
"PORT",
|
||||
"BETTER_AUTH_SECRET",
|
||||
"BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
"NOVU_API_KEY",
|
||||
"EMAIL_UNSUBSCRIBE_SECRET",
|
||||
"REDIS_URL"
|
||||
"REDIS_URL",
|
||||
"LOG_LEVEL"
|
||||
],
|
||||
"globalPassThroughEnv": [
|
||||
"NODE_ENV",
|
||||
|
||||
Reference in New Issue
Block a user