Compare commits

...
Author SHA1 Message Date
Palanikannan M e4f31aea08 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-19 16:01:26 +05:30
Palanikannan M c2a3e47d3d fix: stop event prop on error 2025-03-19 16:01:09 +05:30
Prateek ShouryaandGitHub f82d4a9ead [WEB-3589] improvement: reset language to default on sign out (#6775) 2025-03-19 14:42:19 +05:30
Palanikannan M cef4110eb0 fix: handlers 2025-03-19 14:01:19 +05:30
Palanikannan M 3672ee4ef1 fix: errors and imports 2025-03-18 19:25:31 +05:30
Palanikannan M c56097b8c0 fix: better error handling for redis client 2025-03-18 18:56:50 +05:30
Palanikannan M 0d57e0ab32 fix: file structure for error handling 2025-03-18 17:32:46 +05:30
Palanikannan M df35ccecc9 fix: better error handling 2025-03-18 16:59:04 +05:30
Anmol Singh BhatiaandGitHub 3f22642732 [WEB-3580] fix: automations settings translation (#6767) 2025-03-18 16:34:58 +05:30
e339b7ad8f [WEB-3545] feat: language translations (#6762)
* feat(translations): add Korean translation (#6755)

* feat(translations): init Korean translation

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

* feat(translations): add rough Korean translation

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

---------

Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

* feat(translations): add Slovak, Deutsch, Ukrainian and Polish translation (#6743)

* feat(translation): add Slovak translation

* feat(translation): add Slovak translation for workspace

* feat(translation): improved Slovak translation for views

* feat(translation): add Deutsch translation

* feat(translation): add Ukrainian translation

* feat(translation): add Polish translation

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* fix: project cycle translations

* fix: build error

* feat: Add zh-TW Traditional Chinese locale (#6764)

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>

* chore: zh-TW core translation updated

---------

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>
Co-authored-by: Ján Regeš <jan.reges@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Peter Dave Hello <hsu@peterdavehello.org>
2025-03-18 13:35:46 +05:30
Palanikannan M 38d8d3ea9b fix: dividing server code 2025-03-18 01:36:14 +05:30
Palanikannan M 3710b182d3 fix: tsup hot reloading 2025-03-18 00:16:30 +05:30
Palanikannan M 6897575a62 fix: logger and added working global error handling with sentry 2025-03-17 23:24:03 +05:30
Palanikannan M 388151b70b Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-17 15:52:49 +05:30
Vamsi KrishnaandGitHub d4991b9a48 chore: issue filters refactor (#6742)
* chore: issue filters refactor

* chore: update helper funciton implementation

* chore: removed redundant components
2025-03-17 15:45:34 +05:30
Prateek ShouryaandGitHub 1bf683e044 improvement: command palette search results (#6761) 2025-03-17 15:45:03 +05:30
Akshita GoyalandGitHub 807148671f fix: build (#6760) 2025-03-17 14:38:46 +05:30
d2b81ad2da fix: favorite mutation on quick actions (#6741)
* fix: fav mutation on quick actions

* fix: user favorite fetch

* fix: exist validation

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-17 14:14:45 +05:30
sriram veeraghanta 3d14c9d9fe fix: build test pull request changes 2025-03-17 00:38:51 +05:30
sriram veeraghanta d7f40cf578 fix: remove files changed step from branch build workflow 2025-03-17 00:26:10 +05:30
Aaryan KhandelwalandGitHub b370ef72ee [RANTS-46] fix: modules list sidebar position (#6754) 2025-03-16 23:50:04 +05:30
sriram veeraghanta 0341205666 fix: live server dev port to 3100 2025-03-13 15:42:43 +05:30
sriram veeraghanta 41fe7a59eb chore: axios package update 2025-03-13 14:28:40 +05:30
sriram veeraghanta dcbee45d82 chore: updated package resolutions 2025-03-13 14:05:15 +05:30
Akshita GoyalandGitHub c3560c6586 fix: translation key (#6745) 2025-03-13 13:39:14 +05:30
Anmol Singh BhatiaandGitHub a477f55b23 [WEB-3509] chore: disable search indexing for space app (#6735) 2025-03-11 16:52:25 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9ee1d8cb03 chore(deps): bump the npm_and_yarn group across 6 directories with 2 updates (#6737)
Bumps the npm_and_yarn group with 2 updates in the / directory: [axios](https://github.com/axios/axios) and [tsup](https://github.com/egoist/tsup).
Bumps the npm_and_yarn group with 1 update in the /live directory: [tsup](https://github.com/egoist/tsup).
Bumps the npm_and_yarn group with 1 update in the /packages/editor directory: [tsup](https://github.com/egoist/tsup).
Bumps the npm_and_yarn group with 1 update in the /packages/hooks directory: [tsup](https://github.com/egoist/tsup).
Bumps the npm_and_yarn group with 1 update in the /packages/ui directory: [tsup](https://github.com/egoist/tsup).
Bumps the npm_and_yarn group with 1 update in the /packages/utils directory: [tsup](https://github.com/egoist/tsup).


Updates `axios` from 1.7.9 to 1.8.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

Updates `tsup` from 7.3.0 to 8.3.5
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

Updates `tsup` from 7.3.0 to 8.4.0
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

Updates `tsup` from 7.3.0 to 8.4.0
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

Updates `tsup` from 7.3.0 to 8.4.0
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

Updates `tsup` from 7.3.0 to 8.4.0
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

Updates `tsup` from 7.3.0 to 8.4.0
- [Release notes](https://github.com/egoist/tsup/releases)
- [Commits](https://github.com/egoist/tsup/compare/v7.3.0...v8.3.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: tsup
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 01:14:37 +05:30
sriram veeraghanta b478e36b59 fix: package version upgrade 2025-03-10 18:47:38 +05:30
Vipin ChaudharyandGitHub 2a1cef0360 [PE-262] fix: add break-all to break words (#6734) 2025-03-10 18:37:37 +05:30
Anmol Singh BhatiaandGitHub 52b9b12f74 fix: ambiguous class warning (#6733) 2025-03-10 18:12:51 +05:30
Vamsi KrishnaandGitHub 45ba8cbc83 fix: build errors (#6732) 2025-03-10 17:54:19 +05:30
Akshat JainandGitHub 4ce40fb3db chore: add env MINIO_ENDPOINT_SSL in docker compose file (#6731) 2025-03-10 17:39:07 +05:30
NikhilandGitHub 9ba2ed7d89 chore: make api rate limit configurable through environment variable (#6730) 2025-03-10 17:27:35 +05:30
Vipin ChaudharyandGitHub 6157900b23 [PE-294] fix : route back on page delete (#6729) 2025-03-10 17:19:37 +05:30
Vamsi KrishnaandGitHub a9045adf17 [WEB-3504]chore: issue properties refactor (#6724)
* chore: issue properties refactor

* chore: added export to props

* chore: updated component name
2025-03-10 17:14:46 +05:30
Akshat JainandGitHub d1e462bb37 chore: Add env for handling uploads SSL Termination (#6722) 2025-03-10 15:45:11 +05:30
Anmol Singh BhatiaandGitHub 7df63151b5 [WEB-3134] fix: plane logo rendering issue in safari (#6728) 2025-03-10 15:35:07 +05:30
Anmol Singh BhatiaandGitHub 05b0716822 chore: cs translations updated (#6726) 2025-03-10 14:50:48 +05:30
Anmol Singh BhatiaandGitHub b75c9a8d8d [WEB-3401] fix: platform translations (#6727)
* fix: platform translations

* chore: common translation updated
2025-03-10 14:28:38 +05:30
Ján RegešandGitHub 099c5d50ee feat(translation): add Czech translation (#6725) 2025-03-10 12:41:55 +05:30
Prateek ShouryaandGitHub a953013f70 [WEB-3489] improvement: add support to disable extensions in rich and lite text editor (#6721)
* [WEB-3489] improvement: add support to disable extensions in rich text editor

* improvements: disabled extensions prop for all editor components
2025-03-07 16:34:07 +05:30
sriram veeraghanta a77fe7aa90 fix: django version upgrade to fix vulnerability 2025-03-07 13:35:54 +05:30
Aaryan KhandelwalandGitHub cb344ea1f5 refactor: favorites sidebar implementation (#6716)
* chore: code separation for favorites

* chore: error handling
2025-03-07 13:17:13 +05:30
NikhilandGitHub 40c0bbcfb4 fix: assignee validation when updating issues (#6720)
* fix: assignee validation

* chore: remove prints

* fix: remove all assignees
2025-03-07 13:08:34 +05:30
Aaryan KhandelwalandGitHub 7005ae2b53 chore: add more translation keys (#6715) 2025-03-07 11:31:41 +05:30
Vamsi KrishnaandGitHub 21d7a1865c fix: sidebar project list expand (#6714) 2025-03-06 16:54:28 +05:30
Aaryan KhandelwalandGitHub f65b9a4dcb improvement: add disable image upload using props (#6706) 2025-03-06 16:03:35 +05:30
Prateek ShouryaandGitHub 6d216f2607 [WEB-3482] refactor: platform components and mobx stores (#6713)
* improvement: platform componenents and mobx stores

* minor improvements
2025-03-06 15:47:46 +05:30
Vipin ChaudharyandGitHub 4958be7898 fix: added padding bottom to editor container (#6712) 2025-03-06 15:38:53 +05:30
Vamsi KrishnaandGitHub a40e44c6d5 refactor: issue list modal refactor (#6702) 2025-03-06 13:45:07 +05:30
Akshita GoyalandGitHub 44af90dc6c fix: issue stats refactor (#6705)
* fix: issue stats refactor

* fix: refactor

* fix: ui color

* fix: translation key
2025-03-06 13:44:37 +05:30
Prateek ShouryaandGitHub f01d82ad1e fix: work item assignee update validation (#6704) 2025-03-05 17:42:09 +05:30
Prateek Shouryaandsriram veeraghanta ac6fef3073 [WEB-3488] improvement: assignee validation for work item creation (#6701) 2025-03-05 16:21:09 +05:30
sriram veeraghanta c64c15948b fix: package license repliation 2025-03-04 20:20:38 +05:30
sriram veeraghanta e58b68b6fc fix: esbuild version fix 2025-03-04 20:13:15 +05:30
sriram veeraghanta 68325866ef fix: package version update 2025-03-04 19:32:12 +05:30
Akshita GoyalandGitHub 80198f5fda [WEB-3477] fix: mutation issue on moving work items for a manually ended cycle (#6696) 2025-03-04 18:32:02 +05:30
Akshita GoyalandGitHub 6ac28ad614 fix: module flicker issue on property updation (#6699) 2025-03-04 18:30:53 +05:30
Anmol Singh BhatiaandGitHub c021ffddf2 fix: attachment item created by (#6695) 2025-03-04 13:58:32 +05:30
Palanikannan M 3d61604569 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-02-08 20:32:50 +05:30
Palanikannan M 1b29f65664 fix: removed .js imports 2024-12-23 18:16:53 +05:30
Palanikannan M a229508611 Merge branch 'preview' into devin/1734544044-refactor-live-server 2024-12-23 17:23:59 +05:30
Devin AIandsriram@plane.so <sriram@plane.so> d5bd4ef63a chore: switch from esbuild to tsup for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:49:27 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 146332fff3 chore: replace babel with esbuild for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:42:19 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 5802858772 fix: resolve typescript errors in server and decorators
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:05:01 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> b39ce9c18a fix: add eslint config and fix websocket router interface
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:03:29 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> a7ab5ae680 fix: resolve lint errors in decorators and collaboration controller
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:02:48 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> f2a08853e2 feat: add start.ts entry point for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:01:12 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 23eeb45713 feat: implement collaboration controller with websocket support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:54:15 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 6c83a0df09 feat: implement health check controller with decorator support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:52:40 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> dbee7488e1 feat: add decorator system and controller registration for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:50:52 +00:00
233 changed files with 25161 additions and 5322 deletions
+6
View File
@@ -38,3 +38,9 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
-50
View File
@@ -47,12 +47,6 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
build_admin: ${{ steps.changed_files.outputs.admin_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_web: ${{ steps.changed_files.outputs.web_any_changed }}
build_live: ${{ steps.changed_files.outputs.live_any_changed }}
dh_img_web: ${{ steps.set_env_variables.outputs.DH_IMG_WEB }}
dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }}
@@ -123,46 +117,7 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- nginx/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -185,7 +140,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_web:
if: ${{ needs.branch_build_setup.outputs.build_web == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Web Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -208,7 +162,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Space Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -231,7 +184,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_live:
if: ${{ needs.branch_build_setup.outputs.build_live == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Live Collaboration Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -254,7 +206,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_apiserver:
if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push API Server Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
@@ -277,7 +228,6 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Proxy Docker Image
runs-on: ubuntu-22.04
needs: [branch_build_setup]
+4 -47
View File
@@ -6,49 +6,9 @@ on:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
lint-apiserver:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files_yaml: |
apiserver:
- apiserver/**
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Python
@@ -63,8 +23,7 @@ jobs:
run: ruff check --fix apiserver
lint-admin:
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -76,8 +35,7 @@ jobs:
- run: yarn lint --filter=admin
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -89,8 +47,7 @@ jobs:
- run: yarn lint --filter=space
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+4 -2
View File
@@ -1,6 +1,8 @@
{
"name": "admin",
"version": "0.25.0",
"description": "Admin UI for Plane",
"version": "0.25.2",
"license": "AGPL-3.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -23,7 +25,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.7.9",
"axios": "^1.8.3",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
+7 -1
View File
@@ -59,4 +59,10 @@ APP_BASE_URL=
# Hard delete files after days
HARD_DELETE_AFTER_DAYS=60
HARD_DELETE_AFTER_DAYS=60
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
+4 -1
View File
@@ -1,4 +1,7 @@
{
"name": "plane-api",
"version": "0.25.0"
"version": "0.25.2",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
}
+5 -1
View File
@@ -1,9 +1,13 @@
# python imports
import os
# Third party imports
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = "api_key"
rate = "60/minute"
rate = os.environ.get("API_KEY_RATE_LIMIT", "60/minute")
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
+8 -2
View File
@@ -80,6 +80,7 @@ class IssueSerializer(BaseSerializer):
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
role__gte=15,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@@ -158,8 +159,13 @@ class IssueSerializer(BaseSerializer):
pass
else:
try:
# Then assign it to default assignee
if default_assignee_id is not None:
# Then assign it to default assignee, if it is a valid assignee
if default_assignee_id is not None and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True
).exists():
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
+29 -11
View File
@@ -36,6 +36,7 @@ from plane.db.models import (
State,
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
)
@@ -110,14 +111,23 @@ class IssueCreateSerializer(BaseSerializer):
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, data):
def validate(self, attrs):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
and attrs.get("start_date", None) > attrs.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
if attrs.get("assignee_ids", []):
attrs["assignee_ids"] = ProjectMember.objects.filter(
project_id=self.context["project_id"],
role__gte=15,
is_active=True,
member_id__in=attrs["assignee_ids"],
).values_list("member_id", flat=True)
return attrs
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
@@ -139,22 +149,30 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
assignee_id=assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
for assignee_id in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee
if default_assignee_id is not None:
# Then assign it to default assignee, if it is a valid assignee
if (
default_assignee_id is not None
and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True,
).exists()
):
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
@@ -204,14 +222,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
+4 -2
View File
@@ -178,7 +178,9 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id
).first()
if not intake:
return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND
)
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
@@ -385,7 +387,7 @@ class IntakeIssueViewSet(BaseViewSet):
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True
issue, data=issue_data, partial=True, context={"project_id": project_id}
)
if issue_serializer.is_valid():
+7 -9
View File
@@ -635,7 +635,9 @@ class IssueViewSet(BaseViewSet):
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True, context={"project_id": project_id}
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
@@ -1099,7 +1101,6 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
class IssueMetaEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
@@ -1115,14 +1116,12 @@ class IssueMetaEndpoint(BaseAPIView):
class IssueDetailIdentifierEndpoint(BaseAPIView):
def strict_str_to_int(self, s):
if not s.isdigit() and not (s.startswith('-') and s[1:].isdigit()):
if not s.isdigit() and not (s.startswith("-") and s[1:].isdigit()):
raise ValueError("Invalid integer string")
return int(s)
def get(self, request, slug, project_identifier, issue_identifier):
# Check if the issue identifier is a valid integer
try:
issue_identifier = self.strict_str_to_int(issue_identifier)
@@ -1134,8 +1133,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
workspace__slug=slug,
identifier__iexact=project_identifier, workspace__slug=slug
)
# Check if the user is a member of the project
@@ -1237,8 +1235,8 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
@@ -34,6 +34,22 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
# If the favorite exists return
if request.data.get("entity_identifier"):
user_favorites = UserFavorite.objects.filter(
workspace=workspace,
user_id=request.user.id,
entity_type=request.data.get("entity_type"),
entity_identifier=request.data.get("entity_identifier"),
).first()
# If the favorite exists return
if user_favorites:
serializer = UserFavoriteSerializer(user_favorites)
return Response(serializer.data, status=status.HTTP_200_OK)
# else create a new favorite
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
+7 -1
View File
@@ -32,6 +32,12 @@ class S3Storage(S3Boto3Storage):
) or os.environ.get("MINIO_ENDPOINT_URL")
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
endpoint_protocol = "https"
else:
endpoint_protocol = request.scheme if request else "http"
# Create an S3 client for MinIO
self.s3_client = boto3.client(
"s3",
@@ -39,7 +45,7 @@ class S3Storage(S3Boto3Storage):
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(
f"{request.scheme}://{request.get_host()}"
f"{endpoint_protocol}://{request.get_host()}"
if request
else self.aws_s3_endpoint_url
),
+7 -2
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, State, IssueLink, FileAsset, DeployBoard
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -202,7 +202,12 @@ class IntakeIssuePublicViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True)
issue_serializer = IssueCreateSerializer(
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id},
)
if issue_serializer.is_valid():
current_instance = issue
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.18
Django==4.2.20
# rest framework
djangorestframework==3.15.2
# postgres
+2
View File
@@ -50,6 +50,8 @@ x-app-env: &app-env
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
services:
web:
+5
View File
@@ -58,3 +58,8 @@ GUNICORN_WORKERS=1
# UNCOMMENT `DOCKER_PLATFORM` IF YOU ARE ON `ARM64` AND DOCKER IMAGE IS NOT AVAILABLE FOR RESPECTIVE `APP_RELEASE`
# DOCKER_PLATFORM=linux/amd64
# Force HTTPS for handling SSL Termination
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
+22 -19
View File
@@ -1,20 +1,22 @@
{
"name": "live",
"version": "0.25.0",
"description": "",
"main": "./src/server.ts",
"version": "0.25.2",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
@@ -23,14 +25,16 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^9.0.1",
"@sentry/profiling-node": "^8.28.0",
"@sentry/node": "^9.5.0",
"@sentry/profiling-node": "^9.5.0",
"@sentry/core": "^9.5.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.7.9",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -39,27 +43,26 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.20",
"zod": "^3.24.2",
"@plane/logger": "*"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"@types/reflect-metadata": "^0.1.0",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"tsup": "8.3.0",
"typescript": "5.3.3"
}
}
+18 -7
View File
@@ -1,14 +1,25 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
const { cookie, documentType, pageId, params } = args;
if (!documentType) {
throw new AppError("Document type is required");
}
if (!pageId) {
throw new AppError("Page ID is required");
}
return null
};
+1 -1
View File
@@ -1,5 +1,5 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
+1 -1
View File
@@ -1 +1 @@
export type TAdditionalDocumentTypes = {};
export type TAdditionalDocumentTypes = string;
+66
View File
@@ -0,0 +1,66 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
shutdownTimeout: env.SHUTDOWN_TIMEOUT,
};
@@ -0,0 +1,99 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { Controller, WebSocket } from "@/lib/decorators";
import { BaseWebSocketController } from "@/lib/base.controller";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
@Controller("/collaboration")
export class CollaborationController extends BaseWebSocketController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {
super();
}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,39 @@
import type { Request, Response } from "express";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// controller
import { BaseController } from "@/lib/base.controller";
// decorators
import { Post, CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
// error handling
import validate from "@/core/helpers/error-handling/error-validation";
export class DocumentController extends BaseController {
@Post("/convert-document")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
// Validate input parameters using our new validation utility
validate(description_html, "description_html").required().string();
validate(variant, "variant").required().string();
logger.info("Converting document", { variant });
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { Request, Response } from "express";
import { Controller, Get, CatchErrors } from "@/lib/decorators";
import { BaseController } from "@/lib/base.controller";
@Controller("/health")
export class HealthController extends BaseController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0'
});
}
}
+4
View File
@@ -0,0 +1,4 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
-19
View File
@@ -1,19 +0,0 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
+58
View File
@@ -0,0 +1,58 @@
import { IControllerRegistry, ControllerRegistration, WebSocketControllerRegistration } from "@/lib/controller.interface";
import { createControllerRegistry } from "@/lib/controller.utils";
// Import controllers
import { HealthController } from "@/controllers/health.controller";
import { DocumentController } from "@/controllers/document.controller";
import { CollaborationController } from "@/controllers/collaboration.controller";
/**
* Controller Registry Module
* Centralized place to register all controllers and their dependencies
*/
class ControllerRegistryModule {
// Define controller groups for better organization using registration format
private readonly CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [
{ Controller: HealthController }
],
// Document management controllers
DOCUMENT: [
{ Controller: DocumentController }
],
// WebSocket controllers for real-time functionality
WEBSOCKET: [
{ Controller: CollaborationController, dependencies: ['hocuspocus'] }
],
};
/**
* Get all REST controllers
*/
getAllRestControllers(): ControllerRegistration[] {
return [...this.CONTROLLERS.CORE, ...this.CONTROLLERS.DOCUMENT];
}
/**
* Get all WebSocket controllers
*/
getAllWebSocketControllers(): WebSocketControllerRegistration[] {
return this.CONTROLLERS.WEBSOCKET;
}
/**
* Create a controller registry with all configured controllers
*/
createRegistry(): IControllerRegistry {
return createControllerRegistry(
this.getAllRestControllers(),
this.getAllWebSocketControllers()
);
}
}
// Export a singleton instance
export const controllerRegistry = new ControllerRegistryModule();
+91 -106
View File
@@ -1,142 +1,127 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
// hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
import { setupRedisExtension } from "@/core/extensions/redis";
// types
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { logger } from "@plane/logger";
// error
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// document handlers
import { getDocumentHandler } from "../handlers/document-handlers";
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
logger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
fetch: async ({
context,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
let fetchedData = null;
fetchedData = await catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: 'bad-request',
message: 'Document type is required',
component: 'database-extension',
operation: 'fetch',
extraContext: { pageId },
throw: true
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
const documentHandler = getDocumentHandler(documentType);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
params,
});
if (!fetchedData) {
handleError(null, {
errorType: 'not-found',
message: `Failed to fetch document: ${pageId}`,
component: 'database-extension',
operation: 'fetch',
extraContext: { documentType, pageId }
});
}
return fetchedData;
},
{
params: { pageId, documentType },
extra: { operation: "fetch" },
}
});
)();
return fetchedData;
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
state: Buffer;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const { agentId } = context as HocusPocusServerContext;
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: 'bad-request',
message: 'Document type is required',
component: 'database-extension',
operation: 'store',
extraContext: { pageId },
throw: true
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
const documentHandler = getDocumentHandler(documentType, { agentId });
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
params,
});
},
{
params: { pageId, documentType },
extra: { operation: "store" },
}
});
)();
},
}),
];
const redisUrl = getRedisUrl();
if (redisUrl) {
try {
const redisClient = new Redis(redisUrl);
await new Promise<void>((resolve, reject) => {
redisClient.on("error", (error: any) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH")
) {
redisClient.disconnect();
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
reject(error);
});
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
// Set up Redis extension if available
const redisExtension = await setupRedisExtension();
if (redisExtension) {
extensions.push(redisExtension);
}
return extensions;
+380
View File
@@ -0,0 +1,380 @@
import { Redis } from "ioredis";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
// core helpers and utilities
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
// Keep a reference to the Redis client for cleanup purposes
let redisClient: Redis | null = null;
// Define a custom error type that includes code property
interface RedisError extends Error {
code?: string;
}
// Circuit breaker to disable Redis after too many failures
let redisCircuitBroken = false;
let redisFailureCount = 0;
const MAX_REDIS_FAILURES = 5;
/**
* Sets up the Redis extension for HocusPocus with proper error handling
* @returns Promise that resolves to a Redis extension if successful, or null if Redis is unavailable
*/
export const setupRedisExtension = async (): Promise<Extension | null> => {
// If circuit breaker is active, don't try to connect to Redis
if (redisCircuitBroken) {
logger.warn("Redis circuit breaker active - not attempting to connect");
return null;
}
const redisUrl = getRedisUrl();
if (!redisUrl) {
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
return null;
}
return catchAsync(
async () => {
// Clean up any existing Redis client first
if (redisClient) {
try {
await cleanupRedisClient(redisClient);
} catch (err) {
logger.warn("Error cleaning up previous Redis client", err);
}
redisClient = null;
}
// Create new Redis client with proper options
let client: Redis;
try {
client = new Redis(redisUrl, {
maxRetriesPerRequest: 3, // Limit retries to prevent overwhelming logs
retryStrategy: (times) => {
// Stop retrying after too many failures or if circuit breaker engaged
if (times > 10 || redisCircuitBroken) {
return null; // means stop retrying
}
// Backoff strategy with maximum wait time
const delay = Math.min(times * 100, 3000);
return delay;
},
// Special error handling on connection events
disconnectTimeout: 5000,
enableOfflineQueue: false, // Don't queue commands when disconnected
enableReadyCheck: true,
// Custom commands error handling
showFriendlyErrorStack: true,
});
setupRedisErrorHandlers(client);
// Store reference in module-level variable
redisClient = client;
} catch (err) {
logger.error("Failed to create Redis client", err);
incrementFailureCount();
return null;
}
// Wait for Redis connection or error with a timeout
const result = await new Promise<Extension | AppError>((resolve) => {
let hasResolved = false;
const timeoutId = setTimeout(() => {
if (!hasResolved) {
hasResolved = true;
// Example of non-throwing handleError usage
const error = handleError(new Error("Redis connection timeout"), {
errorType: "service-unavailable",
component: "redis-extension",
operation: "connect",
message: "Redis connection timeout, continuing without Redis",
});
logger.warn(error.message);
incrementFailureCount();
// Clean up the client
cleanupRedisClient(client);
resolve(error);
}
}, 5000);
client.once("ready", () => {
if (!hasResolved) {
hasResolved = true;
clearTimeout(timeoutId);
// Reset failure count on successful connection
redisFailureCount = 0;
logger.info("Redis client connected successfully ✅");
try {
// Create extension with error handling
const redisExtension = new HocusPocusRedis({
redis: client,
prefix: "plane:",
});
// Add safe destroy method to the extension
const extension = redisExtension as unknown as {
destroy?: () => Promise<void> | void;
onDestroy?: () => Promise<void> | void;
};
// Store the original destroy method
const originalDestroy = extension.destroy;
// Replace with our safe version
extension.destroy = async () => {
logger.info("Cleaning up Redis extension...");
try {
// Safely clean up the Redis client
await cleanupRedisClient(client);
} catch (err) {
logger.warn("Error cleaning up Redis client during destroy", err);
}
// Call original destroy method if it exists
if (originalDestroy) {
try {
await Promise.resolve(originalDestroy.call(extension));
} catch (err) {
logger.warn("Error in original destroy method", err);
}
}
};
resolve(redisExtension);
} catch (err) {
// Handle errors in extension setup
logger.error("Error creating Redis extension", err);
cleanupRedisClient(client);
incrementFailureCount();
resolve(
handleError(err as Error, {
errorType: "service-unavailable",
component: "redis-extension",
operation: "create-extension",
message: "Failed to create Redis extension",
})
);
}
}
});
client.once("error", (error: RedisError) => {
if (!hasResolved) {
hasResolved = true;
clearTimeout(timeoutId);
// Increment failure count
incrementFailureCount();
// Non-throwing usage with specific error details
const appError = handleError(error, {
errorType: "service-unavailable",
component: "redis-extension",
operation: "connect",
message: `Redis client wasn't able to connect, continuing without Redis`,
extraContext: {
redisUrl: redisUrl.replace(/\/\/.*:.*@/, "//***:***@"), // Mask credentials
errorCode: error?.code,
errorMessage: error?.message,
},
});
logger.warn(appError.message, { errorCode: error?.code });
// Clean up the client
cleanupRedisClient(client);
resolve(appError);
}
});
});
// If result is an error, return null to indicate Redis is not available
if (result instanceof AppError) {
redisClient = null;
return null;
}
return result;
},
{
extra: {
component: "redis-extension",
operation: "setup",
redisUrl: redisUrl.replace(/\/\/.*:.*@/, "//***:***@"), // Mask credentials for logging
},
},
{
defaultValue: null, // Return null if any error occurs
rethrow: false, // Never rethrow, always gracefully continue without Redis
}
)().catch((err) => {
// Extra safety net - catch any errors the catchAsync might miss
logger.error("Uncaught error in Redis setup", err);
return null;
});
};
/**
* Handle multiple failures with circuit breaker pattern
*/
function incrementFailureCount(): void {
redisFailureCount++;
if (redisFailureCount >= MAX_REDIS_FAILURES) {
logger.error(`Redis failures reached threshold (${MAX_REDIS_FAILURES}), activating circuit breaker`);
redisCircuitBroken = true;
// Cleanup any existing client
if (redisClient) {
cleanupRedisClient(redisClient).catch((err) =>
logger.error("Error cleaning up Redis client during circuit break", err)
);
redisClient = null;
}
// Set a timeout to reset the circuit breaker
setTimeout(() => {
logger.info("Redis circuit breaker reset, will try connecting again on next request");
redisCircuitBroken = false;
redisFailureCount = 0;
}, 60000); // Try again after 1 minute
}
}
/**
* Set up global error handlers for the Redis client
* This prevents unhandled errors from crashing the application
*/
function setupRedisErrorHandlers(client: Redis) {
// Capture ALL error events
client.on("error", (err: RedisError) => {
// Only increment failures for certain types of errors
if (
err.message?.includes("ECONNREFUSED") ||
err.message?.includes("ETIMEDOUT") ||
err.message?.includes("EPIPE") ||
err.message?.includes("MaxRetriesPerRequestError") ||
err.code === "ECONNREFUSED"
) {
incrementFailureCount();
}
// Log the error but don't crash
handleError(err, {
errorType: "service-unavailable",
component: "redis-extension",
operation: "ongoing-connection",
message: `Redis error occurred (will attempt to recover)`,
extraContext: {
errorCode: err?.code,
errorMessage: err?.message,
},
});
// Log at warn level so we don't flood logs with errors
logger.warn(`Redis error: ${err.message}`, {
errorCode: err?.code,
stack: err?.stack,
});
});
// Handle specific reconnection errors that might indicate Redis is down
client.on("reconnecting", (time: number) => {
logger.info(`Redis reconnecting after ${time}ms...`);
});
// Handle MaxRetriesPerRequestError specifically
process.on("unhandledRejection", (reason: unknown) => {
// Only handle Redis-related errors
if (
reason instanceof Error &&
(reason.message.includes("MaxRetriesPerRequestError") || reason.message.includes("Redis"))
) {
logger.warn("Caught unhandled Redis-related rejection", {
message: reason.message,
stack: reason.stack,
});
incrementFailureCount();
// Don't let it crash the process
return;
}
// Let other unhandled rejections pass through to the default handler
});
// Handle connection end events
client.on("end", () => {
logger.warn("Redis connection ended");
// This is normal during shutdown, don't increment failure count
});
}
/**
* Safely clean up a Redis client connection
*/
async function cleanupRedisClient(client: Redis | null): Promise<void> {
if (!client) return;
try {
// Remove all listeners to prevent memory leaks
client.removeAllListeners();
// Quit the connection gracefully
if (client.status !== "end") {
await Promise.race([
client.quit().catch(() => {
// If quit fails, force disconnect
client.disconnect();
}),
// Safety timeout in case quit hangs
new Promise<void>((resolve) =>
setTimeout(() => {
client.disconnect();
resolve();
}, 1000)
),
]);
}
} catch (err) {
// Just force disconnect if anything goes wrong
try {
client.disconnect();
} catch (disconnectErr) {
// At this point, we've tried our best
logger.error("Failed to properly disconnect Redis client", disconnectErr);
}
}
}
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "circuit-broken" | "not-configured" => {
if (redisCircuitBroken) return "circuit-broken";
if (!redisClient) return "not-configured";
switch (redisClient.status) {
case "ready":
return "connected";
case "connect":
case "reconnecting":
return "connecting";
default:
return "disconnected";
}
};
@@ -0,0 +1,32 @@
import { DocumentHandler, HandlerContext, HandlerDefinition } from "./types";
/**
* Class that manages handler selection based on multiple criteria
*/
export class DocumentHandlerFactory {
private handlers: HandlerDefinition[] = [];
/**
* Register a handler with its selection criteria
*/
register(definition: HandlerDefinition): void {
this.handlers.push(definition);
// Sort handlers by priority (highest first)
this.handlers.sort((a, b) => b.priority - a.priority);
}
/**
* Get the appropriate handler based on the provided context
*/
getHandler(context: HandlerContext): DocumentHandler {
// Find the first handler whose selector returns true
const matchingHandler = this.handlers.find(h => h.selector(context));
// Return the matching handler or fall back to null/undefined
// (This will cause an error if no handlers match, which is good for debugging)
return matchingHandler?.handler as DocumentHandler;
}
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
@@ -0,0 +1,31 @@
import { DocumentHandler, HandlerContext } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
// Import handler definitions
import { projectPageHandlerDefinition } from "@/core/handlers/document-handlers/project-page-handler";
// Register handlers
handlerFactory.register(projectPageHandlerDefinition);
/**
* Get a document handler based on the provided context criteria
* @param documentType The primary document type
* @param additionalContext Optional additional context criteria
* @returns The appropriate document handler
*/
export function getDocumentHandler(
documentType: string,
additionalContext: Omit<HandlerContext, 'documentType'> = {}
): DocumentHandler {
// Create a context object with all criteria
const context: HandlerContext = {
documentType: documentType as any,
...additionalContext
};
// Use the factory to get the appropriate handler
return handlerFactory.getHandler(context);
}
// Export the factory for direct access if needed
export { handlerFactory };
@@ -0,0 +1,30 @@
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page";
import { DocumentHandler, DocumentFetchParams, DocumentStoreParams, HandlerDefinition } from "./types";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
},
/**
* Store project page description
*/
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription(params, pageId, state, cookie);
}
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page" && !context.agentId,
handler: projectPageHandler,
priority: 10 // Standard priority
};
-21
View File
@@ -1,21 +0,0 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
@@ -0,0 +1,277 @@
import { AppError, HttpStatusCode, ErrorCategory } from "./error-handler";
/**
* Map of error types to their corresponding factory functions
* This ensures that error types and their implementations stay in sync
*/
interface ErrorFactory {
statusCode: number;
category: ErrorCategory;
defaultMessage: string;
createError: (message?: string, context?: Record<string, any>) => AppError;
}
const ERROR_FACTORIES: Record<string, ErrorFactory> = {
"bad-request": {
statusCode: HttpStatusCode.BAD_REQUEST,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Bad Request",
createError: (message = "Bad Request", context?) =>
new AppError(message, HttpStatusCode.BAD_REQUEST, ErrorCategory.OPERATIONAL, context),
},
unauthorized: {
statusCode: HttpStatusCode.UNAUTHORIZED,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unauthorized",
createError: (message = "Unauthorized", context?) =>
new AppError(message, HttpStatusCode.UNAUTHORIZED, ErrorCategory.OPERATIONAL, context),
},
forbidden: {
statusCode: HttpStatusCode.FORBIDDEN,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Forbidden",
createError: (message = "Forbidden", context?) =>
new AppError(message, HttpStatusCode.FORBIDDEN, ErrorCategory.OPERATIONAL, context),
},
"not-found": {
statusCode: HttpStatusCode.NOT_FOUND,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource not found",
createError: (message = "Resource not found", context?) =>
new AppError(message, HttpStatusCode.NOT_FOUND, ErrorCategory.OPERATIONAL, context),
},
conflict: {
statusCode: HttpStatusCode.CONFLICT,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Resource conflict",
createError: (message = "Resource conflict", context?) =>
new AppError(message, HttpStatusCode.CONFLICT, ErrorCategory.OPERATIONAL, context),
},
"unprocessable-entity": {
statusCode: HttpStatusCode.UNPROCESSABLE_ENTITY,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Unprocessable Entity",
createError: (message = "Unprocessable Entity", context?) =>
new AppError(message, HttpStatusCode.UNPROCESSABLE_ENTITY, ErrorCategory.OPERATIONAL, context),
},
"too-many-requests": {
statusCode: HttpStatusCode.TOO_MANY_REQUESTS,
category: ErrorCategory.OPERATIONAL,
defaultMessage: "Too many requests",
createError: (message = "Too many requests", context?) =>
new AppError(message, HttpStatusCode.TOO_MANY_REQUESTS, ErrorCategory.OPERATIONAL, context),
},
internal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.PROGRAMMING,
defaultMessage: "Internal Server Error",
createError: (message = "Internal Server Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.PROGRAMMING, context),
},
"service-unavailable": {
statusCode: HttpStatusCode.SERVICE_UNAVAILABLE,
category: ErrorCategory.SYSTEM,
defaultMessage: "Service Unavailable",
createError: (message = "Service Unavailable", context?) =>
new AppError(message, HttpStatusCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM, context),
},
fatal: {
statusCode: HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory.FATAL,
defaultMessage: "Fatal Error",
createError: (message = "Fatal Error", context?) =>
new AppError(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context),
},
};
// Create the type from the keys of the error factories map
export type ErrorType = keyof typeof ERROR_FACTORIES;
// -------------------------------------------------------------------------
// Primary public API - Recommended for most use cases
// -------------------------------------------------------------------------
/**
* Base options for handleError function
*/
type BaseErrorHandlerOptions = {
// Error classification options
errorType?: ErrorType;
message?: string;
// Context information
component: string;
operation: string;
extraContext?: Record<string, any>;
// Behavior options
rethrowIfAppError?: boolean;
};
/**
* Options for throwing variant of handleError - discriminated by throw: true
*/
export type ThrowingOptions = BaseErrorHandlerOptions & {
throw: true;
};
/**
* Options for non-throwing variant of handleError - default behavior
*/
export type NonThrowingOptions = BaseErrorHandlerOptions;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options with throw: true to throw the error instead of returning it
* @returns Never returns - always throws
* @example
* // Throwing version
* handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById',
* throw: true
* });
*/
export function handleError(error: unknown, options: ThrowingOptions): never;
/**
* Unified error handler that encapsulates common error handling patterns
*
* @param error The error to handle
* @param options Configuration options (non-throwing by default)
* @returns The AppError instance
* @example
* // Non-throwing version (default)
* const appError = handleError(error, {
* errorType: 'not-found',
* component: 'user-service',
* operation: 'getUserById'
* });
* return { error: appError.output() };
*/
export function handleError(error: unknown, options: NonThrowingOptions): AppError;
/**
* Implementation of handleError that handles both throwing and non-throwing cases
*/
export function handleError(error: unknown, options: ThrowingOptions | NonThrowingOptions): AppError | never {
console.log("error", error instanceof AppError);
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
// If the error is already an AppError and we want to rethrow it as is
if (options.rethrowIfAppError !== false && error instanceof AppError) {
if (shouldThrow) {
throw error;
}
return error;
}
// Format the error message
const errorMessage = options.message
? error instanceof Error
? `${options.message}: ${error.message}`
: error
? `${options.message}: ${String(error)}`
: options.message
: error instanceof Error
? error.message
: error
? String(error)
: "Unknown error occurred";
// Build context object
const context = {
component: options.component,
operation: options.operation,
originalError: error,
...(options.extraContext || {}),
};
// Create the appropriate error type using our factory map
const errorType = options.errorType || "internal";
const factory = ERROR_FACTORIES[errorType];
if (!factory) {
// If no factory found, default to internal error
return ERROR_FACTORIES.internal.createError(errorMessage, context);
}
// Create the error with the factory
const appError = factory.createError(errorMessage, context);
// If we should throw, do so now
if (shouldThrow) {
throw appError;
}
return appError;
}
/**
* Utility function to convert errors or enhance existing AppErrors
*/
export const convertError = (
error: Error,
options?: {
statusCode?: number;
message?: string;
category?: ErrorCategory;
context?: Record<string, any>;
}
): AppError => {
if (error instanceof AppError) {
// If it's already an AppError and no overrides, return as is
if (!options?.statusCode && !options?.message && !options?.category) {
return error;
}
// Create a new AppError with the original as context
return new AppError(
options?.message || error.message,
options?.statusCode || error.status,
options?.category || error.category,
{
...(error.context || {}),
...(options?.context || {}),
originalError: error,
}
);
}
// Determine the appropriate error type based on status code
let errorType: ErrorType = "internal";
if (options?.statusCode) {
// Find the error type that matches the status code
const entry = Object.entries(ERROR_FACTORIES).find(([_, factory]) => factory.statusCode === options.statusCode);
if (entry) {
errorType = entry[0] as ErrorType;
}
}
// Return a new AppError using the factory
return handleError(error, {
errorType: errorType,
message: options?.message,
component: options?.context?.component || "unknown",
operation: options?.context?.operation || "convert-error",
extraContext: options?.context,
});
};
/**
* Check if an error is an AppError
*/
export const isAppError = (err: any, statusCode?: number): boolean => {
return err instanceof AppError && (!statusCode || err.status === statusCode);
};
// Export only the public API
export default {
handleError,
convertError,
isAppError,
};
@@ -0,0 +1,417 @@
import { ErrorRequestHandler, Request, Response, NextFunction } from "express";
import { SentryInstance, captureException } from "@/sentry-config";
import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
import { APIService } from "@/core/services/api.service";
/**
* HTTP Status Codes
*/
export enum HttpStatusCode {
// 2xx Success
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NO_CONTENT = 204,
// 4xx Client Errors
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
CONFLICT = 409,
GONE = 410,
UNPROCESSABLE_ENTITY = 422,
TOO_MANY_REQUESTS = 429,
// 5xx Server Errors
INTERNAL_SERVER = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
}
/**
* Error categories to classify errors
*/
export enum ErrorCategory {
OPERATIONAL = "operational", // Expected errors that are part of normal operation (e.g. validation failures)
PROGRAMMING = "programming", // Unexpected errors that indicate bugs (e.g. null references)
SYSTEM = "system", // System errors (e.g. out of memory, connection failures)
FATAL = "fatal", // Severe errors that should crash the app (e.g. unrecoverable state)
}
/**
* Base Application Error Class
* All custom errors extend this class
*/
export class AppError extends Error {
readonly status: number;
readonly category: ErrorCategory;
readonly context?: Record<string, any>;
readonly isOperational: boolean; // Kept for backward compatibility
constructor(
message: string,
status: number = HttpStatusCode.INTERNAL_SERVER,
category: ErrorCategory = ErrorCategory.PROGRAMMING,
context?: Record<string, any>
) {
super(message);
// Set error properties
this.name = this.constructor.name;
this.status = status;
this.category = category;
this.isOperational = category === ErrorCategory.OPERATIONAL;
this.context = context;
// Capture stack trace, excluding the constructor call from the stack
Error.captureStackTrace(this, this.constructor);
// Automatically report the error (unless it's being constructed by the error utilities)
if (!context?.skipReporting) {
this.report();
}
}
/**
* Creates a formatted representation of the error
*/
output() {
return {
statusCode: this.status,
payload: {
statusCode: this.status,
error: this.getErrorName(),
message: this.message,
category: this.category,
},
headers: {},
};
}
/**
* Gets a descriptive name for the error based on status code
*/
private getErrorName(): string {
const statusCodes: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
410: "Gone",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
return statusCodes[this.status] || "Unknown Error";
}
/**
* Reports the error to logging and monitoring systems
*/
private report(): void {
// Different logging based on error category
if (this.category === ErrorCategory.OPERATIONAL) {
manualLogger.error(`Operational error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
context: this.context,
});
} else if (this.category === ErrorCategory.FATAL) {
manualLogger.error(`FATAL error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
} else {
manualLogger.error(`${this.category} error: ${this.message}`, {
errorName: this.name,
errorStatus: this.status,
errorCategory: this.category,
stack: this.stack,
context: this.context,
});
}
// Send to Sentry if configured
if (SentryInstance) {
captureException(this, {
extra: {
...this.context,
isOperational: this.isOperational,
errorCategory: this.category,
status: this.status,
},
});
}
}
}
export class FatalError extends AppError {
constructor(message: string, context?: Record<string, any>) {
super(message, HttpStatusCode.INTERNAL_SERVER, ErrorCategory.FATAL, context);
}
}
/**
* Main Express error handler middleware
*/
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
// Already sent response, let default Express error handler deal with it
if (res.headersSent) {
return next(err);
}
// Convert to AppError if it's not already one
const error = handleError(err, {
component: "express",
operation: "error-handler",
extraContext: {
originalError: err,
url: req.originalUrl,
method: req.method,
},
});
// Normalize status code
const statusCode = error.status;
// Set the response status
res.status(statusCode);
// Set any custom headers if provided in the error object
if (err.headers && typeof err.headers === "object") {
Object.entries(err.headers).forEach(([key, value]) => {
res.set(key, value as string);
});
}
// Prepare error response
const errorResponse: {
error: {
message: string;
status: number;
stack?: string;
};
} = {
error: {
message:
error.category === ErrorCategory.OPERATIONAL || env.NODE_ENV !== "production"
? error.message
: "An unexpected error occurred",
status: statusCode,
},
};
// Add stack trace in non-production environments
if (env.NODE_ENV !== "production") {
errorResponse.error.stack = error.stack;
}
// Send the response
res.json(errorResponse);
// If it's a fatal error, initiate shutdown after sending response
if (error.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR OCCURRED! Initiating shutdown...`);
process.emit("SIGTERM");
}
};
export const asyncHandler = (fn: Function) => {
return (req: any, res: any, next: any) => {
Promise.resolve(fn(req, res, next)).catch((error) => {
// Convert to AppError if needed and pass to Express error middleware
const appError = handleError(error, {
errorType: "internal",
component: "express",
operation: "route-handler",
extraContext: {
url: req.originalUrl,
method: req.method,
body: req.body,
query: req.query,
params: req.params,
},
});
next(appError);
});
};
};
export interface CatchAsyncOptions<T, E = Error> {
/** Default value to return in case of error, null by default */
defaultValue?: T | null;
/** Whether to report non-AppErrors automatically */
reportErrors?: boolean;
/** Whether to rethrow the error after handling it */
rethrow?: boolean;
/** Custom error transformer function */
transformError?: (error: unknown) => E;
/** Custom error handler function that runs before standard handling */
onError?: (error: unknown) => void | Promise<void>;
/** Custom handler for specific error types */
errorHandlers?: {
[key: string]: (error: any) => T | null | Promise<T>;
};
}
export const catchAsync = <T, E = Error>(
fn: () => Promise<T>,
context?: ErrorContext,
options: CatchAsyncOptions<T, E> = {}
): (() => Promise<T | null>) => {
const { defaultValue = null, onError, rethrow = false } = options;
return async () => {
try {
return await fn();
} catch (error) {
// Apply custom error handler if provided
if (onError) {
await Promise.resolve(onError(error));
}
reportError(error, context);
if (error instanceof AppError) {
error.context;
}
if (rethrow) {
// Use handleError to ensure consistent error handling when rethrowing
handleError(error, {
component: context?.extra?.component || "unknown",
operation: context?.extra?.operation || "unknown",
extraContext: {
...context,
...(error instanceof AppError ? error.context : {}),
originalError: error,
},
throw: true,
});
}
return defaultValue;
}
};
};
/**
* Sets up global error handlers for unhandled rejections and exceptions
* @param gracefulShutdown Function to call for graceful shutdown
*/
export const setupGlobalErrorHandlers = (gracefulShutdown: () => Promise<void>): void => {
// Handle promise rejections
process.on("unhandledRejection", (reason: unknown) => {
logger.error("Unhandled Promise Rejection", { reason });
if (SentryInstance) {
SentryInstance.captureException(reason);
}
// Convert to AppError and handle
const appError = handleError(reason, {
errorType: "internal",
message: reason instanceof Error ? reason.message : String(reason),
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
// Only crash if it's a fatal error
if (appError.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR ENCOUNTERED! Shutting down...`);
gracefulShutdown();
} else {
logger.error(`Unhandled rejection caught and contained`);
}
});
// Handle exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception", {
error: error.message,
stack: error.stack,
});
if (SentryInstance) {
SentryInstance.captureException(error);
}
// Convert to AppError if needed
const appError = handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: {
source: "uncaughtException",
},
});
// Only crash for fatal errors or if error handling system itself is broken
if (appError.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR ENCOUNTERED! Shutting down...`);
gracefulShutdown();
} else {
logger.warn(`Uncaught exception contained - this should be investigated!`);
}
});
// Handle termination signals
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Shutting down gracefully...");
gracefulShutdown();
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Shutting down gracefully...");
gracefulShutdown();
});
};
/**
* Configure error handling middleware for the Express app
* @param app Express application instance
*/
export function configureErrorHandlers(app: any): void {
// Global error handling middleware
app.use(errorHandler);
// 404 handler must be last
app.use((_req: Request, _res: Response, next: NextFunction) => {
next(
handleError(null, {
errorType: "not-found",
message: "Resource not found",
component: "express",
operation: "route-handler",
extraContext: { path: _req.path },
throw: true,
})
);
});
}
@@ -0,0 +1,56 @@
import { SentryInstance } from "@/sentry-config";
import { AppError, ErrorCategory } from "./error-handler";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
export interface ErrorContext {
url?: string;
method?: string;
body?: any;
query?: any;
params?: any;
extra?: Record<string, any>;
}
/**
* Utility function to report errors that aren't instances of AppError
* AppError instances automatically report themselves on creation
* Only use this for external errors that don't use our error system
*/
export const reportError = (error: Error | unknown, context?: ErrorContext): void => {
if (error instanceof AppError) {
// if it's an app error, don't report it as it's already been reported
return;
}
logger.error(`External error: ${error instanceof Error ? error.stack || error.message : String(error)}`, {
error,
context,
});
// Send to Sentry
if (SentryInstance) {
SentryInstance.captureException(error, {
extra: {
...context,
errorCategory: ErrorCategory.PROGRAMMING,
},
});
}
};
export const handleFatalError = (error: Error | unknown, context?: ErrorContext): void => {
// Convert to fatal AppError
const fatalError = handleError(error, {
errorType: "fatal",
message: error instanceof Error ? error.message : String(error),
component: context?.extra?.component || "system",
operation: context?.extra?.operation || "fatal-error-handler",
extraContext: {
...context,
originalError: error,
},
});
process.emit("uncaughtException", fatalError);
};
@@ -0,0 +1,158 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
+261
View File
@@ -0,0 +1,261 @@
import { handleError } from "./error-handling/error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: this.name, format: regex.toString() },
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: this.name, allowedValues },
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
export class ValidationError extends Error {
constructor(public name: string, message: string) {
super(message);
this.name = name;
}
}
export const validateRequired = (value: any, name: string, message?: string) => {
if (value === undefined || value === null) {
throw handleError(new ValidationError(name, message || `${name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: name },
throw: true
});
}
};
export const validateString = (value: any, name: string, message?: string) => {
if (typeof value !== 'string') {
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
if (!value.trim()) {
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNumber = (value: any, name: string, message?: string) => {
if (typeof value !== 'number' || isNaN(value)) {
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: name },
throw: true
});
}
};
export const validateArray = (value: any, name: string, message?: string) => {
if (!Array.isArray(value) || value.length === 0) {
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: name },
throw: true
});
}
};
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
if (!format.test(value)) {
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: name, format: format.toString() },
throw: true
});
}
};
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
if (!allowedValues.includes(value)) {
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: name, allowedValues },
throw: true
});
}
};
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
if (!validator(value)) {
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: name },
throw: true
});
}
};
+74 -47
View File
@@ -1,17 +1,18 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
@@ -21,53 +22,79 @@ export const getHocusPocusServer = async () => {
onAuthenticate: async ({
requestHeaders,
context,
requestParameters,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext; // Better than 'any', still allows property assignment
requestParameters: URLSearchParams;
token: string;
}) => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// need to rethrow all errors since hocuspocus needs to know to stop
// further propagation of events to other document lifecycle
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie");
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{
rethrow: true,
}
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
try {
await handleAuthentication({
cookie,
userId,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
)();
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
onStateless: async ({ payload, document }) => {
return catchAsync(
async () => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
{ extra: { operation: "stateless", payload } }
);
},
extensions,
debounce: 10000,
debounce: 1000,
});
};
+27 -7
View File
@@ -1,27 +1,47 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId } = props;
const { cookie, userId, workspaceSlug } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
throw error;
console.log("caught?");
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
if (response.id !== userId) {
throw Error("Authentication failed: Token doesn't match the current user.");
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
return {
+24 -72
View File
@@ -1,112 +1,64 @@
// helpers
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { PageService } from "@/core/services/page.service";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array",
);
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
throw error;
}
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(
workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary;
} catch (error) {
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
throw error;
}
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
try {
const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binaryData = new Uint8Array(response);
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
if (binary) {
return binary;
}
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
throw error;
}
return binaryData;
};
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class PageService extends APIService {
constructor() {
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class UserService extends APIService {
constructor() {
+106
View File
@@ -0,0 +1,106 @@
// server
import { Server } from "http";
// hocuspocus server
import type { Hocuspocus } from "@hocuspocus/server";
// logger
import { logger } from "@plane/logger";
// config
import { serverConfig } from "@/config/server-config";
/**
* ShutdownManager handles graceful shutdown of server resources
*/
export class ShutdownManager {
private readonly hocusPocusServer: Hocuspocus;
private readonly httpServer: Server;
/**
* Initialize the shutdown manager
* @param hocusPocusServer Hocuspocus server instance
* @param httpServer HTTP server instance
*/
constructor(hocusPocusServer: Hocuspocus, httpServer: Server) {
this.hocusPocusServer = hocusPocusServer;
this.httpServer = httpServer;
}
/**
* Register shutdown handlers with the process
*/
registerShutdownHandlers(): void {
const gracefulShutdown = this.getGracefulShutdown();
// Handle process signals
process.on("SIGTERM", gracefulShutdown);
process.on("SIGINT", gracefulShutdown);
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
gracefulShutdown();
});
// Handle unhandled promise rejections
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
gracefulShutdown();
});
}
/**
* Get the graceful shutdown handler
* @returns Shutdown function
*/
private getGracefulShutdown(): () => Promise<void> {
return async () => {
logger.info("Starting graceful shutdown...");
let hasShutdownCompleted = false;
// Create a timeout that will force exit if shutdown takes too long
const forceExitTimeout = setTimeout(() => {
if (!hasShutdownCompleted) {
logger.error("Forcing shutdown after timeout - some connections may not have closed gracefully.");
process.exit(1);
}
}, serverConfig.shutdownTimeout);
// Destroy Hocuspocus server first
logger.info("Shutting down Hocuspocus server...");
let hocuspocusShutdownSuccessful = false;
try {
await this.hocusPocusServer.destroy();
hocuspocusShutdownSuccessful = true;
logger.info("HocusPocus server WebSocket connections closed gracefully.");
} catch (error) {
hocuspocusShutdownSuccessful = false;
logger.error("Error during hocuspocus server shutdown:", error);
// Continue with HTTP server shutdown even if Hocuspocus shutdown fails
} finally {
logger.info(
`Proceeding to HTTP server shutdown. Hocuspocus shutdown ${hocuspocusShutdownSuccessful ? "was successful" : "had errors"}.`
);
}
// Close HTTP server
try {
logger.info("Initiating HTTP server shutdown...");
this.httpServer.close(() => {
logger.info("HTTP server closed gracefully - all connections ended.");
// Clear the timeout since we're shutting down gracefully
clearTimeout(forceExitTimeout);
hasShutdownCompleted = true;
process.exit(0);
});
logger.info("HTTP server close initiated, waiting for connections to end...");
} catch (error) {
logger.error("Error during HTTP server shutdown:", error);
clearTimeout(forceExitTimeout);
process.exit(1);
}
};
}
}
+6 -1
View File
@@ -1,10 +1,15 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId: string;
workspaceSlug: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
};
export type TConvertDocumentRequestBody = {
+62
View File
@@ -0,0 +1,62 @@
import { HocusPocusServerContext, TDocumentTypes } from "@/core/types/common";
/**
* Parameters for document fetch operations
*/
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
params: URLSearchParams;
}
/**
* Parameters for document store operations
*/
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: any;
params: URLSearchParams;
}
/**
* Interface defining a document handler
*/
export interface DocumentHandler {
/**
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
}
/**
* Handler context interface - extend this to add new criteria for handler selection
*/
export interface HandlerContext {
documentType?: TDocumentTypes;
agentId?: string;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: HandlerContext) => boolean;
/**
* Handler definition combining a selector and implementation
*/
export interface HandlerDefinition {
selector: HandlerSelector;
handler: DocumentHandler;
priority: number; // Higher number means higher priority
}
/**
* Type for a handler registration function
*/
export type RegisterHandler = (definition: HandlerDefinition) => void;
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/authentication.js"
export * from "../../core/lib/authentication";
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/fetch-document.js"
export * from "../../ce/lib/fetch-document";
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/update-document.js"
export * from "../../ce/lib/update-document";
+1 -1
View File
@@ -1 +1 @@
export * from "../../ce/types/common.js"
export * from "../../ce/types/common"
+52
View File
@@ -0,0 +1,52 @@
import * as dotenv from "dotenv";
import { z } from "zod";
// Load environment variables from .env file
dotenv.config();
// Define environment schema with validation
const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().default("3000").transform(Number),
LIVE_BASE_PATH: z.string().default("/live"),
// CORS configuration
CORS_ALLOWED_ORIGINS: z
.string()
.default("*"),
// Sentry configuration
LIVE_SENTRY_DSN: z.string().optional(),
LIVE_SENTRY_ENVIRONMENT: z.string().default("development"),
LIVE_SENTRY_TRACES_SAMPLE_RATE: z.string().default("0.5").transform(Number),
LIVE_SENTRY_RELEASE: z.string().optional(),
LIVE_SENTRY_RELEASE_VERSION: z.string().optional(),
// Compression options
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// Hocuspocus server configuration
HOCUSPOCUS_URL: z.string().optional(),
HOCUSPOCUS_USERNAME: z.string().optional(),
HOCUSPOCUS_PASSWORD: z.string().optional(),
// Graceful shutdown timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
});
// Validate the environment variables
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
process.exit(1);
}
return result.data;
}
// Export the validated environment
export const env = validateEnv();
+92
View File
@@ -0,0 +1,92 @@
import { Router, Request } from "express";
import { WebSocket } from "ws";
import "reflect-metadata";
import Errors from "@/core/helpers/error-handling/error-factory";
import { ErrorCategory ,asyncHandler} from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
export abstract class BaseController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Register all routes for this controller
*/
public registerRoutes(router: Router): void {
const baseRoute = this.getBaseRoute();
const proto = Object.getPrototypeOf(this);
const methods = Object.getOwnPropertyNames(proto).filter(
(item) => item !== "constructor" && typeof (this as any)[item] === "function"
);
methods.forEach((methodName) => {
const route = Reflect.getMetadata("route", proto, methodName) || "";
const method = Reflect.getMetadata("method", proto, methodName) as string;
const middlewares = Reflect.getMetadata("middlewares", proto, methodName) || [];
if (route && method) {
const fullRoute = baseRoute + route;
const handler = (this as any)[methodName].bind(this);
if (method === "ws") {
// Handle WebSocket routes with error handling for graceful failures
(router as any).ws(fullRoute, (ws: WebSocket, req: Request) => {
try {
handler(ws, req);
} catch (error: unknown) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
controller: this.constructor.name,
method: methodName,
route: fullRoute,
requestId: req.id || "unknown",
},
});
logger.error(`WebSocket error in ${this.constructor.name}.${methodName}`, {
error: appError,
});
// Send error message to client before closing
try {
ws.send(
JSON.stringify({
type: "error",
message:
appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
} catch (sendError) {
// Ignore send errors at this point
}
// Close the connection with an appropriate code
ws.close(1011, appError.message);
}
});
} else {
(router as any)[method](fullRoute, ...middlewares, asyncHandler(handler));
}
}
});
// Mount this controller's router on the main router
router.use(baseRoute, this.router);
}
}
export abstract class BaseWebSocketController extends BaseController {
abstract handleConnection(ws: WebSocket, req: Request): void;
}
+64
View File
@@ -0,0 +1,64 @@
import { Router, Request } from "express";
import { WebSocket } from "ws";
import { BaseController, BaseWebSocketController } from "./base.controller";
/**
* Controller factory function interface for creating controllers with dependencies
*/
export type ControllerFactory<T extends BaseController = BaseController> = (dependencies: any) => T;
/**
* Interface for controller registration with optional dependencies
*/
export interface ControllerRegistration {
Controller: new (...args: any[]) => BaseController;
dependencies?: string[];
}
/**
* Interface for WebSocket controller registration with optional dependencies
*/
export interface WebSocketControllerRegistration {
Controller: new (...args: any[]) => BaseWebSocketController;
dependencies?: string[];
}
/**
* Controller registry interface for organizing controllers
*/
export interface IControllerRegistry {
controllers: ControllerRegistration[];
webSocketControllers: WebSocketControllerRegistration[];
}
/**
* Service container interface for dependency management
*/
export interface IServiceContainer {
get(serviceName: string): any;
register(serviceName: string, service: any): void;
}
/**
* Base controller interface that all controllers should implement
*/
export interface IController {
/**
* Register routes for RESTful endpoints
* @param router Express router to register routes on
*/
registerRoutes?(router: Router): void;
}
/**
* WebSocket controller interface for websocket handlers
*/
export interface IWebSocketController extends IController {
/**
* Handle WebSocket connections
* @param ws WebSocket connection
* @param req Express request object
*/
handleConnection(ws: WebSocket, req: Request): void;
}
+50
View File
@@ -0,0 +1,50 @@
import { Router } from "express";
import { IControllerRegistry, IServiceContainer, ControllerRegistration, WebSocketControllerRegistration } from "./controller.interface";
import "reflect-metadata";
// Define valid HTTP methods
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'all' | 'use' | 'options' | 'head' | 'ws';
/**
* Register all controllers from a registry with dependency injection
* @param router Express router to register routes on
* @param registry Controller registry containing all controllers
* @param container Service container for dependency injection
*/
export function registerControllers(
router: Router,
registry: IControllerRegistry,
container: IServiceContainer
): void {
// Register REST controllers
registry.controllers.forEach((registration) => {
const { Controller, dependencies = [] } = registration;
const resolvedDependencies = dependencies.map(dep => container.get(dep));
const instance = new Controller(...resolvedDependencies);
instance.registerRoutes(router);
});
// Register WebSocket controllers
registry.webSocketControllers.forEach((registration) => {
const { Controller, dependencies = [] } = registration;
const resolvedDependencies = dependencies.map(dep => container.get(dep));
const instance = new Controller(...resolvedDependencies);
instance.registerRoutes(router);
});
}
/**
* Create a controller registry with the given controllers
* @param controllers Array of REST controller registrations
* @param webSocketControllers Array of WebSocket controller registrations
* @returns Controller registry
*/
export function createControllerRegistry(
controllers: ControllerRegistration[],
webSocketControllers: WebSocketControllerRegistration[] = []
): IControllerRegistry {
return {
controllers,
webSocketControllers,
};
}
+78
View File
@@ -0,0 +1,78 @@
import "reflect-metadata";
import type { RequestHandler } from "express";
import { asyncHandler } from "@/core/helpers/error-handling/error-handler";
export const Controller = (baseRoute = ""): ClassDecorator => {
return function (target: object) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
};
export const Get = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "get", target, propertyKey);
};
};
export const Post = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "post", target, propertyKey);
};
};
export const Put = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "put", target, propertyKey);
};
};
export const Patch = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "patch", target, propertyKey);
};
};
export const Delete = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "delete", target, propertyKey);
};
};
export const Middleware = (middleware: RequestHandler): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
};
export const WebSocket = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "ws", target, propertyKey);
};
};
/**
* Decorator to wrap controller methods with error handling
* This automatically catches and processes all errors using our error handling system
*/
export const CatchErrors = (): MethodDecorator => {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// Only apply to methods that are not WebSocket handlers
const isWebSocketHandler = Reflect.getMetadata("method", target, propertyKey) === "ws";
if (typeof originalMethod === 'function' && !isWebSocketHandler) {
descriptor.value = asyncHandler(originalMethod);
}
return descriptor;
};
};
+30
View File
@@ -0,0 +1,30 @@
import { IServiceContainer } from "./controller.interface";
/**
* Simple service container implementation for dependency injection
*/
export class ServiceContainer implements IServiceContainer {
private services: Map<string, any> = new Map();
/**
* Register a service in the container
* @param serviceName Name of the service
* @param service The service instance
*/
register(serviceName: string, service: any): void {
this.services.set(serviceName, service);
}
/**
* Get a service from the container
* @param serviceName Name of the service
* @returns The service instance
* @throws Error if service not found
*/
get(serviceName: string): any {
if (!this.services.has(serviceName)) {
throw new Error(`Service ${serviceName} not found in container`);
}
return this.services.get(serviceName);
}
}
+27
View File
@@ -0,0 +1,27 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
import { env } from "@/env";
import { logger } from "@plane/logger";
export const initializeSentry = () => {
if (!env.LIVE_SENTRY_DSN) {
logger.warn("Sentry DSN not configured");
return;
}
logger.info(`Initializing Sentry | Version:${env.LIVE_SENTRY_RELEASE_VERSION}`);
Sentry.init({
dsn: env.LIVE_SENTRY_DSN,
integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()],
tracesSampleRate: 1.0,
profilesSampleRate: 1.0,
environment: env.NODE_ENV,
release: env.LIVE_SENTRY_RELEASE_VERSION,
});
};
export const captureException = (err: Error, context?: Record<string, any>) => {
Sentry.captureException(err, context);
};
export const SentryInstance = Sentry;
+155 -121
View File
@@ -1,140 +1,174 @@
import * as Sentry from "@sentry/node";
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// config
import "@/core/config/sentry-config.js";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
import type { Application, Request, Router } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
const app = express();
expressWs(app);
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
import { initializeSentry } from "./sentry-config";
app.set("port", process.env.PORT || 3000);
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { controllerRegistry } from "@/core/controller-registry";
import { ShutdownManager } from "@/core/shutdown-manager";
// Security middleware
app.use(helmet());
// Service and controller related
import { IControllerRegistry, IServiceContainer } from "./lib/controller.interface";
import { registerControllers } from "./lib/controller.utils";
import { ServiceContainer } from "./lib/service-container";
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
})
);
// Logging
import { logger } from "@plane/logger";
// Logging middleware
app.use(logger);
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
// cors middleware
app.use(cors());
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private hocusPocusServer!: Hocuspocus;
private controllerRegistry!: IControllerRegistry;
private serviceContainer: IServiceContainer;
const router = express.Router();
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
this.app = express();
this.serviceContainer = new ServiceContainer();
this.port = port || serverConfig.port;
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
// Initialize express-ws after Express setup
expressWs(this.app as any);
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
// Configure server
this.setupSentry();
configureServerMiddleware(this.app);
}
});
router.post("/convert-document", (req, res) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
}
/**
* Set up Sentry for error tracking
*/
private setupSentry(): void {
initializeSentry();
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
try {
// Initialize core services
await this.initializeServices();
// Initialize controllers
await this.initializeControllers();
// Set up routes
await this.setupRoutes();
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
return this;
} catch (error) {
logger.error("Failed to initialize server:", error);
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
/**
* Initialize core services
*/
private async initializeServices() {
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Register services in the container
this.serviceContainer.register("hocuspocus", this.hocusPocusServer);
}
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
/**
* Initialize controllers
*/
private async initializeControllers() {
// Create controller registry with all controllers
this.controllerRegistry = controllerRegistry.createRegistry();
}
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
// Register all controllers using the registry with the service container
registerControllers(router, this.controllerRegistry, this.serviceContainer);
// Mount the router on the base path
this.app.use(serverConfig.basePath, router);
} catch (error) {
handleError(error, {
errorType: "internal",
component: "server",
operation: "setupRoutes",
throw: true,
});
}
}
/**
* Start the server
* @returns HTTP Server instance
*/
async start() {
try {
const server = this.app.listen(this.port, () => {
logger.info(`Plane Live server has started at port ${this.port}`);
});
// Setup graceful shutdown
const shutdownManager = new ShutdownManager(this.hocusPocusServer, server);
shutdownManager.registerShutdownHandlers();
return server;
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
});
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Server } from "./server";
import { env } from "./env";
import { logger } from "@plane/logger";
/**
* The main entry point for the application
* Starts the server and handles any startup errors
*/
const startServer = async () => {
try {
// Log server startup details
logger.info(`Starting Plane Live server in ${env.NODE_ENV} environment`);
// Initialize and start the server
const server = await new Server().initialize();
await server.start();
logger.info(`Server running at base path: ${env.LIVE_BASE_PATH}`);
} catch (error) {
logger.error("Failed to start server:", error);
process.exit(1);
}
};
// Start the server
startServer();
+22 -10
View File
@@ -1,26 +1,38 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+15 -9
View File
@@ -1,11 +1,17 @@
import { defineConfig, Options } from "tsup";
import { defineConfig } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
export default defineConfig({
entry: ["src/start.ts"],
format: ["esm", "cjs"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
splitting: false,
sourcemap: true,
clean: true,
minify: false,
target: "node18",
outDir: "dist",
env: {
NODE_ENV: process.env.NODE_ENV || "development",
},
watch: ["src/**/*.{ts,tsx}"],
});
+7 -4
View File
@@ -1,6 +1,8 @@
{
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.25.0",
"version": "0.25.2",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -26,8 +28,9 @@
},
"resolutions": {
"nanoid": "3.3.8",
"esbuild": "0.25.0"
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
"packageManager": "yarn@1.22.22"
}
+3 -2
View File
@@ -1,6 +1,7 @@
{
"name": "@plane/constants",
"version": "0.25.0",
"version": "0.25.2",
"private": true,
"main": "./src/index.ts"
"main": "./src/index.ts",
"license": "AGPL-3.0"
}
+160 -24
View File
@@ -1,8 +1,4 @@
import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IIssueDisplayProperties,
} from "@plane/types";
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
export const ALL_ISSUES = "All Issues";
@@ -149,25 +145,24 @@ export const ISSUE_ORDER_BY_OPTIONS: {
{ key: "-priority", titleTranslationKey: "common.priority" },
];
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] =
[
"assignee",
"start_date",
"due_date",
"labels",
"key",
"priority",
"state",
"sub_issue_count",
"link",
"attachment_count",
"estimate",
"created_on",
"updated_on",
"modules",
"cycle",
"issue_type",
];
export const ISSUE_DISPLAY_PROPERTIES_KEYS: (keyof IIssueDisplayProperties)[] = [
"assignee",
"start_date",
"due_date",
"labels",
"key",
"priority",
"state",
"sub_issue_count",
"link",
"attachment_count",
"estimate",
"created_on",
"updated_on",
"modules",
"cycle",
"issue_type",
];
export const ISSUE_DISPLAY_PROPERTIES: {
key: keyof IIssueDisplayProperties;
@@ -215,3 +210,144 @@ export const ISSUE_DISPLAY_PROPERTIES: {
{ key: "modules", titleTranslationKey: "common.module" },
{ key: "cycle", titleTranslationKey: "common.cycle" },
];
export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [
"state",
"priority",
"assignee",
"labels",
"modules",
"cycle",
"start_date",
"due_date",
"estimate",
"created_on",
"updated_on",
"link",
"attachment_count",
"sub_issue_count",
];
export const SPREADSHEET_PROPERTY_DETAILS: {
[key in keyof IIssueDisplayProperties]: {
i18n_title: string;
ascendingOrderKey: TIssueOrderByOptions;
ascendingOrderTitle: string;
descendingOrderKey: TIssueOrderByOptions;
descendingOrderTitle: string;
icon: string;
};
} = {
assignee: {
i18n_title: "common.assignees",
ascendingOrderKey: "assignees__first_name",
ascendingOrderTitle: "A",
descendingOrderKey: "-assignees__first_name",
descendingOrderTitle: "Z",
icon: "Users",
},
created_on: {
i18n_title: "common.sort.created_on",
ascendingOrderKey: "-created_at",
ascendingOrderTitle: "New",
descendingOrderKey: "created_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
due_date: {
i18n_title: "common.order_by.due_date",
ascendingOrderKey: "-target_date",
ascendingOrderTitle: "New",
descendingOrderKey: "target_date",
descendingOrderTitle: "Old",
icon: "CalendarCheck2",
},
estimate: {
i18n_title: "common.estimate",
ascendingOrderKey: "estimate_point__key",
ascendingOrderTitle: "Low",
descendingOrderKey: "-estimate_point__key",
descendingOrderTitle: "High",
icon: "Triangle",
},
labels: {
i18n_title: "common.labels",
ascendingOrderKey: "labels__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-labels__name",
descendingOrderTitle: "Z",
icon: "Tag",
},
modules: {
i18n_title: "common.modules",
ascendingOrderKey: "issue_module__module__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_module__module__name",
descendingOrderTitle: "Z",
icon: "DiceIcon",
},
cycle: {
i18n_title: "common.cycle",
ascendingOrderKey: "issue_cycle__cycle__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_cycle__cycle__name",
descendingOrderTitle: "Z",
icon: "ContrastIcon",
},
priority: {
i18n_title: "common.priority",
ascendingOrderKey: "priority",
ascendingOrderTitle: "None",
descendingOrderKey: "-priority",
descendingOrderTitle: "Urgent",
icon: "Signal",
},
start_date: {
i18n_title: "common.order_by.start_date",
ascendingOrderKey: "-start_date",
ascendingOrderTitle: "New",
descendingOrderKey: "start_date",
descendingOrderTitle: "Old",
icon: "CalendarClock",
},
state: {
i18n_title: "common.state",
ascendingOrderKey: "state__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-state__name",
descendingOrderTitle: "Z",
icon: "DoubleCircleIcon",
},
updated_on: {
i18n_title: "common.sort.updated_on",
ascendingOrderKey: "-updated_at",
ascendingOrderTitle: "New",
descendingOrderKey: "updated_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
link: {
i18n_title: "common.link",
ascendingOrderKey: "-link_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "link_count",
descendingOrderTitle: "Least",
icon: "Link2",
},
attachment_count: {
i18n_title: "common.attachment",
ascendingOrderKey: "-attachment_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "attachment_count",
descendingOrderTitle: "Least",
icon: "Paperclip",
},
sub_issue_count: {
i18n_title: "issue.display.properties.sub_issue",
ascendingOrderKey: "-sub_issues_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "sub_issues_count",
descendingOrderTitle: "Least",
icon: "LayersIcon",
},
};
+1
View File
@@ -1,3 +1,4 @@
export * from "./common";
export * from "./filter";
export * from "./layout";
export * from "./modal";
+19
View File
@@ -0,0 +1,19 @@
// plane imports
import { TIssue } from "@plane/types";
export const DEFAULT_WORK_ITEM_FORM_VALUES: Partial<TIssue> = {
project_id: "",
type_id: null,
name: "",
description_html: "",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
+6 -9
View File
@@ -1,8 +1,5 @@
// icons
import {
TProjectAppliedDisplayFilterKeys,
TProjectOrderByOptions,
} from "@plane/types";
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -55,11 +52,11 @@ export const GROUP_CHOICES = {
};
export const PROJECT_AUTOMATION_MONTHS = [
{ i18n_label: "common.months_count", value: 1 },
{ i18n_label: "common.months_count", value: 3 },
{ i18n_label: "common.months_count", value: 6 },
{ i18n_label: "common.months_count", value: 9 },
{ i18n_label: "common.months_count", value: 12 },
{ i18n_label: "workspace_projects.common.months_count", value: 1 },
{ i18n_label: "workspace_projects.common.months_count", value: 3 },
{ i18n_label: "workspace_projects.common.months_count", value: 6 },
{ i18n_label: "workspace_projects.common.months_count", value: 9 },
{ i18n_label: "workspace_projects.common.months_count", value: 12 },
];
export const PROJECT_UNSPLASH_COVERS = [
+13 -1
View File
@@ -1,4 +1,4 @@
import { TStaticViewTypes } from "@plane/types";
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
import { EUserWorkspaceRoles } from "./user";
export const ORGANIZATION_SIZE = [
@@ -324,3 +324,15 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarN
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];
export const WORKSPACE_DEFAULT_SEARCH_RESULT: IWorkspaceSearchResults = {
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
};
+3 -2
View File
@@ -1,7 +1,8 @@
{
"name": "@plane/editor",
"version": "0.25.0",
"version": "0.25.2",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -80,7 +81,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "^7.2.0",
"tsup": "8.3.0",
"typescript": "5.3.3"
},
"keywords": [
@@ -76,7 +76,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent.maxFileSize}
maxFileSize={editor.storage.imageComponent?.maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
@@ -16,7 +16,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
// subscribe to image upload status
const uploadStatus: number | undefined = useEditorState({
editor,
selector: ({ editor }) => editor.storage.imageComponent.assetsUploadStatus[nodeId],
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
});
useEffect(() => {
@@ -22,7 +22,7 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
@@ -50,8 +50,7 @@ type TArguments = {
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
return [
// @ts-expect-error tiptap types are incorrect
const extensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -109,12 +108,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomTypographyExtension,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -152,7 +145,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.imageComponent.uploadInProgress) return "";
if (editor.storage.imageComponent?.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") ||
@@ -179,4 +172,18 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
disabledExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension(fileHandler)
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};
@@ -48,6 +48,7 @@ export const CustomImageComponentWithoutProps = () =>
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
assetsUploadStatus: {},
};
},
});
@@ -41,8 +41,7 @@ type Props = {
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
return [
// @ts-expect-error tiptap types are incorrect
const extensions = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -94,12 +93,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -136,4 +129,18 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
disabledExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(fileHandler)
);
}
// @ts-expect-error tiptap types are incorrect
return extensions;
};
@@ -43,7 +43,7 @@ import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/typ
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TExtensionProps } from "./root";
import { TExtensionProps, TSlashCommandAdditionalOption } from "./root";
export type TSlashCommandSection = {
key: TSlashCommandSectionKeys;
@@ -54,7 +54,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions, disabledExtensions } = args;
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -176,15 +176,6 @@ export const getSlashCommandFilteredSections =
icon: <Code2 className="size-3.5" />,
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
commandKey: "image",
key: "image",
title: "Image",
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
commandKey: "callout",
key: "callout",
@@ -284,8 +275,24 @@ export const getSlashCommandFilteredSections =
},
];
const internalAdditionalOptions: TSlashCommandAdditionalOption[] = [];
if (!disabledExtensions?.includes("image")) {
internalAdditionalOptions.push({
commandKey: "image",
key: "image",
title: "Image",
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
section: "general",
pushAfter: "code",
});
}
[
...(additionalOptions ?? []),
...internalAdditionalOptions,
...(externalAdditionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
}),
+3 -3
View File
@@ -111,7 +111,7 @@ export const useEditor = (props: CustomEditorProps) => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value == null) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
if (editor && !editor.isDestroyed && !editor.storage.imageComponent?.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
if (editor.state.selection) {
@@ -129,7 +129,7 @@ export const useEditor = (props: CustomEditorProps) => {
useEffect(() => {
if (!editor) return;
const assetsUploadStatus = fileHandler.assetsUploadStatus;
editor.commands.updateAssetsUploadStatus(assetsUploadStatus);
editor.commands.updateAssetsUploadStatus?.(assetsUploadStatus);
}, [editor, fileHandler.assetsUploadStatus]);
useImperativeHandle(
@@ -221,7 +221,7 @@ export const useEditor = (props: CustomEditorProps) => {
if (!editor) return;
scrollSummary(editor, marking);
},
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage.imageComponent?.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
@@ -21,7 +21,9 @@ export const useUploader = (args: TUploaderArgs) => {
const uploadFile = useCallback(
async (file: File) => {
const setImageUploadInProgress = (isUploading: boolean) => {
editor.storage.imageComponent.uploadInProgress = isUploading;
if (editor.storage.imageComponent) {
editor.storage.imageComponent.uploadInProgress = isUploading;
}
};
setImageUploadInProgress(true);
setUploading(true);
+1 -1
View File
@@ -1 +1 @@
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key";
export type TExtensions = "ai" | "collaboration-cursor" | "issue-embed" | "slash-commands" | "enter-key" | "image";
+2 -1
View File
@@ -1,7 +1,8 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.25.0",
"version": "0.25.2",
"license": "AGPL-3.0",
"files": [
"library.js",
"next.js",
+3 -2
View File
@@ -1,6 +1,7 @@
{
"name": "@plane/hooks",
"version": "0.25.0",
"version": "0.25.2",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
@@ -21,7 +22,7 @@
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"tsup": "^7.2.0",
"tsup": "8.3.0",
"typescript": "^5.3.3"
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "@plane/i18n",
"version": "0.25.0",
"version": "0.25.2",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+9 -2
View File
@@ -7,9 +7,16 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "Français", value: "fr" },
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "中文", value: "zh-CN" },
{ label: "简体中文", value: "zh-CN" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "Русский", value: "ru" },
{ label: "Italian", value: "it" },
{ label: "Čeština", value: "cs" },
{ label: "Slovenčina", value: "sk" },
{ label: "Deutsch", value: "de" },
{ label: "Українська", value: "ua" },
{ label: "Polski", value: "pl" },
{ label: "한국어", value: "ko" },
];
export const STORAGE_KEY = "userLanguage";
export const LANGUAGE_STORAGE_KEY = "userLanguage";
+4 -4
View File
@@ -1,8 +1,8 @@
import { useContext } from 'react';
import { useContext } from "react";
// context
import { TranslationContext } from '../context';
import { TranslationContext } from "../context";
// types
import { ILanguageOption, TLanguage } from '../types';
import { ILanguageOption, TLanguage } from "../types";
export type TTranslationStore = {
t: (key: string, params?: Record<string, any>) => string;
@@ -23,7 +23,7 @@ export type TTranslationStore = {
export function useTranslation(): TTranslationStore {
const store = useContext(TranslationContext);
if (!store) {
throw new Error('useTranslation must be used within a TranslationProvider');
throw new Error("useTranslation must be used within a TranslationProvider");
}
return {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -529,6 +529,7 @@
"property": "Property",
"properties": "Properties",
"parent": "Parent",
"page": "Page",
"remove": "Remove",
"archiving": "Archiving",
"archive": "Archive",
@@ -687,7 +688,7 @@
"you": "You",
"upgrade_cta": {
"higher_subscription": "Upgrade to higher subscription",
"talk_to_sales": "Talk to sales"
"talk_to_sales": "Talk to Sales"
},
"category": "Category",
"categories": "Categories",
@@ -696,7 +697,8 @@
"delete": "Delete",
"deleting": "Deleting",
"pending": "Pending",
"invite": "Invite"
"invite": "Invite",
"view": "View"
},
"chart": {
@@ -814,7 +816,8 @@
"sub_issue_count": "Sub-work item count",
"attachment_count": "Attachment count",
"created_on": "Created on",
"sub_issue": "Sub-work item"
"sub_issue": "Sub-work item",
"work_item_count": "Work item count"
},
"extra": {
"show_sub_issues": "Show sub-work items",
@@ -1302,7 +1305,8 @@
"max_length": "Workspace name should not exceed 80 characters"
},
"company_size": {
"required": "Company size is required"
"required": "Company size is required",
"select_a_range": "Select organization size"
}
}
},
@@ -701,6 +701,7 @@
"property": "Propiedad",
"properties": "Propiedades",
"parent": "Padre",
"page": "página",
"remove": "Eliminar",
"archiving": "Archivando",
"archive": "Archivar",
@@ -867,7 +868,8 @@
"delete": "Eliminar",
"deleting": "Eliminando",
"pending": "Pendiente",
"invite": "Invitar"
"invite": "Invitar",
"view": "Ver"
},
"chart": {
@@ -985,7 +987,8 @@
"sub_issue_count": "Cantidad de sub-elementos",
"attachment_count": "Cantidad de archivos adjuntos",
"created_on": "Creado el",
"sub_issue": "Sub-elemento de trabajo"
"sub_issue": "Sub-elemento de trabajo",
"work_item_count": "Recuento de elementos de trabajo"
},
"extra": {
"show_sub_issues": "Mostrar sub-elementos",
@@ -1472,7 +1475,8 @@
"max_length": "El nombre del espacio de trabajo no debe exceder los 80 caracteres"
},
"company_size": {
"required": "El tamaño de la empresa es obligatorio"
"required": "El tamaño de la empresa es obligatorio",
"select_a_range": "Seleccionar tamaño de la organización"
}
}
},
@@ -699,6 +699,7 @@
"property": "Propriété",
"properties": "Propriétés",
"parent": "Parent",
"page": "Pâge",
"remove": "Supprimer",
"archiving": "Archivage",
"archive": "Archiver",
@@ -856,7 +857,7 @@
"you": "Vous",
"upgrade_cta": {
"higher_subscription": "Passer à une abonnement plus élevé",
"talk_to_sales": "Parler à la vente"
"talk_to_sales": "Parler aux ventes"
},
"category": "Catégorie",
"categories": "Catégories",
@@ -865,7 +866,8 @@
"delete": "Supprimer",
"deleting": "Suppression",
"pending": "En attente",
"invite": "Inviter"
"invite": "Inviter",
"view": "Afficher"
},
"chart": {
@@ -983,7 +985,8 @@
"sub_issue_count": "Nombre de sous-éléments",
"attachment_count": "Nombre de pièces jointes",
"created_on": "Créé le",
"sub_issue": "Sous-élément de travail"
"sub_issue": "Sous-élément de travail",
"work_item_count": "Nombre d'éléments de travail"
},
"extra": {
"show_sub_issues": "Afficher les sous-éléments",
@@ -1470,7 +1473,8 @@
"max_length": "Le nom de l'espace de travail ne doit pas dépasser 80 caractères"
},
"company_size": {
"required": "La taille de l'entreprise est requise"
"required": "La taille de l'entreprise est requise",
"select_a_range": "Sélectionner la taille de l'organisation"
}
}
},
@@ -693,6 +693,7 @@
"property": "Proprietà",
"properties": "Proprietà",
"parent": "Principale",
"page": "Pagina",
"remove": "Rimuovi",
"archiving": "Archiviazione in corso",
"archive": "Archivia",
@@ -862,7 +863,8 @@
"delete": "Elimina",
"deleting": "Eliminazione in corso",
"pending": "In sospeso",
"invite": "Invita"
"invite": "Invita",
"view": "Visualizza"
},
"chart": {
@@ -980,7 +982,8 @@
"sub_issue_count": "Numero di sotto-elementi di lavoro",
"attachment_count": "Numero di allegati",
"created_on": "Creato il",
"sub_issue": "Sotto-elemento di lavoro"
"sub_issue": "Sotto-elemento di lavoro",
"work_item_count": "Conteggio degli elementi di lavoro"
},
"extra": {
"show_sub_issues": "Mostra sotto-elementi di lavoro",
@@ -1468,7 +1471,8 @@
"max_length": "Il nome dello spazio di lavoro non deve superare gli 80 caratteri"
},
"company_size": {
"required": "La dimensione aziendale è obbligatoria"
"required": "La dimensione aziendale è obbligatoria",
"select_a_range": "Seleziona la dimensione dell'organizzazione"
}
}
},
@@ -699,6 +699,7 @@
"property": "プロパティ",
"properties": "プロパティ",
"parent": "親",
"page": "ページ",
"remove": "削除",
"archiving": "アーカイブ中",
"archive": "アーカイブ",
@@ -856,7 +857,7 @@
"you": "あなた",
"upgrade_cta": {
"higher_subscription": "高いサブスクリプションにアップグレード",
"talk_to_sales": "セールスに連絡"
"talk_to_sales": "トーク トゥ セールス"
},
"category": "カテゴリー",
"categories": "カテゴリーズ",
@@ -865,7 +866,8 @@
"delete": "デリート",
"deleting": "デリーティング",
"pending": "保留中",
"invite": "招待"
"invite": "招待",
"view": "ビュー"
},
"chart": {
@@ -983,7 +985,8 @@
"sub_issue_count": "サブ作業項目数",
"attachment_count": "添付ファイル数",
"created_on": "作成日",
"sub_issue": "サブ作業項目"
"sub_issue": "サブ作業項目",
"work_item_count": "作業項目数"
},
"extra": {
"show_sub_issues": "サブ作業項目を表示",
@@ -1470,7 +1473,8 @@
"max_length": "ワークスペース名は80文字を超えることはできません"
},
"company_size": {
"required": "会社の規模は必須です"
"required": "会社の規模は必須です",
"select_a_range": "組織の規模を選択"
}
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -697,6 +697,7 @@
"property": "Свойство",
"properties": "Свойства",
"parent": "Родительский",
"page": "Пейдж",
"remove": "Удалить",
"archiving": "Архивация",
"archive": "Архивировать",
@@ -864,7 +865,8 @@
"delete": "Удалить",
"deleting": "Удаление",
"pending": "Ожидание",
"invite": "Пригласить"
"invite": "Пригласить",
"view": "Просмотр"
},
"chart": {
@@ -982,7 +984,8 @@
"sub_issue_count": "Количество подэлементов",
"attachment_count": "Количество вложений",
"created_on": "Дата создания",
"sub_issue": "Подэлемент"
"sub_issue": "Подэлемент",
"work_item_count": "Количество рабочих элементов"
},
"extra": {
"show_sub_issues": "Показывать подэлементы",
@@ -1470,7 +1473,8 @@
"max_length": "Максимум 80 символов"
},
"company_size": {
"required": "Размер компании обязателен"
"required": "Размер компании обязателен",
"select_a_range": "Выберите размер организации"
}
}
},
@@ -1866,7 +1870,7 @@
}
},
"completed_no_issues": {
"title": "Нет рабочих элементов в цикле",
"title": "Нет рабочих элементов в цикле",
"description": "Нет рабочих элементов. Рабочие элементы были перенесены или скрыты. Для просмотра измените настройки отображения."
},
"active": {
@@ -2285,7 +2289,7 @@
"short_description": "Экспорт в csv"
},
"excel": {
"title": "Excel",
"title": "Excel",
"description": "Экспорт рабочих элементов в файл Excel.",
"short_description": "Экспорт в excel"
},
@@ -2303,7 +2307,7 @@
"default_global_view": {
"all_issues": "Все рабочие элементы",
"assigned": "Назначенные",
"created": "Созданные",
"created": "Созданные",
"subscribed": "Подписанные"
},
@@ -2332,7 +2336,7 @@
"project_modules": {
"status": {
"backlog": "Бэклог",
"planned": "Запланировано",
"planned": "Запланировано",
"in_progress": "В процессе",
"paused": "Приостановлено",
"completed": "Завершено",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -699,6 +699,7 @@
"property": "属性",
"properties": "属性",
"parent": "父项",
"page": "页面",
"remove": "移除",
"archiving": "归档中",
"archive": "归档",
@@ -865,7 +866,8 @@
"delete": "删除",
"deleting": "删除中",
"pending": "待处理",
"invite": "邀请"
"invite": "邀请",
"view": "查看"
},
"chart": {
@@ -983,7 +985,8 @@
"sub_issue_count": "子工作项数量",
"attachment_count": "附件数量",
"created_on": "创建于",
"sub_issue": "子工作项"
"sub_issue": "子工作项",
"work_item_count": "工作项数量"
},
"extra": {
"show_sub_issues": "显示子工作项",
@@ -1470,7 +1473,8 @@
"max_length": "工作区名称不应超过80个字符"
},
"company_size": {
"required": "公司规模为必填项"
"required": "公司规模为必填项",
"select_a_range": "选择组织规模"
}
}
},
File diff suppressed because it is too large Load Diff
+19 -39
View File
@@ -3,7 +3,7 @@ import get from "lodash/get";
import merge from "lodash/merge";
import { makeAutoObservable, runInAction } from "mobx";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY } from "../constants";
// core translations imports
import coreEn from "../locales/en/core.json";
// types
@@ -48,14 +48,14 @@ export class TranslationStore {
private initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
const savedLocale = localStorage.getItem(LANGUAGE_STORAGE_KEY) as TLanguage;
if (this.isValidLanguage(savedLocale)) {
this.setLanguage(savedLocale);
return;
}
const browserLang = this.getBrowserLanguage();
this.setLanguage(browserLang);
// Fallback to default language
this.setLanguage(FALLBACK_LANGUAGE);
}
/** Loads the translations for the current language */
@@ -147,10 +147,24 @@ export class TranslationStore {
return import("../locales/ja/translations.json");
case "zh-CN":
return import("../locales/zh-CN/translations.json");
case "zh-TW":
return import("../locales/zh-TW/translations.json");
case "ru":
return import("../locales/ru/translations.json");
case "it":
return import("../locales/it/translations.json");
case "cs":
return import("../locales/cs/translations.json");
case "sk":
return import("../locales/sk/translations.json");
case "de":
return import("../locales/de/translations.json");
case "ua":
return import("../locales/ua/translations.json");
case "pl":
return import("../locales/pl/translations.json");
case "ko":
return import("../locales/ko/translations.json");
default:
throw new Error(`Unsupported language: ${language}`);
}
@@ -161,40 +175,6 @@ export class TranslationStore {
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
}
/** Checks if a language code is similar to any supported language */
private findSimilarLanguage(lang: string): TLanguage | null {
// Convert to lowercase for case-insensitive comparison
const normalizedLang = lang.toLowerCase();
// Find a supported language that includes or is included in the browser language
const similarLang = this.availableLanguages.find(
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
);
return similarLang ? similarLang.value : null;
}
/** Gets the browser language based on the navigator.language */
private getBrowserLanguage(): TLanguage {
const browserLang = navigator.language;
// Check exact match first
if (this.isValidLanguage(browserLang)) {
return browserLang;
}
// Check base language without region code
const baseLang = browserLang.split("-")[0];
if (this.isValidLanguage(baseLang)) {
return baseLang as TLanguage;
}
// Try to find a similar language
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
return similarLang || FALLBACK_LANGUAGE;
}
/**
* Gets the cache key for the given key and locale
* @param key - the key to get the cache key for
@@ -279,7 +259,7 @@ export class TranslationStore {
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, lng);
localStorage.setItem(LANGUAGE_STORAGE_KEY, lng);
document.documentElement.lang = lng;
}

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