Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f31aea08 | ||
|
|
c2a3e47d3d | ||
|
|
f82d4a9ead | ||
|
|
cef4110eb0 | ||
|
|
3672ee4ef1 | ||
|
|
c56097b8c0 | ||
|
|
0d57e0ab32 | ||
|
|
df35ccecc9 | ||
|
|
3f22642732 | ||
|
|
e339b7ad8f | ||
|
|
38d8d3ea9b | ||
|
|
3710b182d3 | ||
|
|
6897575a62 | ||
|
|
388151b70b | ||
|
|
d4991b9a48 | ||
|
|
1bf683e044 | ||
|
|
807148671f | ||
|
|
d2b81ad2da | ||
|
|
3d14c9d9fe | ||
|
|
d7f40cf578 | ||
|
|
b370ef72ee | ||
|
|
0341205666 | ||
|
|
41fe7a59eb | ||
|
|
dcbee45d82 | ||
|
|
c3560c6586 | ||
|
|
a477f55b23 | ||
|
|
9ee1d8cb03 | ||
|
|
b478e36b59 | ||
|
|
2a1cef0360 | ||
|
|
52b9b12f74 | ||
|
|
45ba8cbc83 | ||
|
|
4ce40fb3db | ||
|
|
9ba2ed7d89 | ||
|
|
6157900b23 | ||
|
|
a9045adf17 | ||
|
|
d1e462bb37 | ||
|
|
7df63151b5 | ||
|
|
05b0716822 | ||
|
|
b75c9a8d8d | ||
|
|
099c5d50ee | ||
|
|
a953013f70 | ||
|
|
a77fe7aa90 | ||
|
|
cb344ea1f5 | ||
|
|
40c0bbcfb4 | ||
|
|
7005ae2b53 | ||
|
|
21d7a1865c | ||
|
|
f65b9a4dcb | ||
|
|
6d216f2607 | ||
|
|
4958be7898 | ||
|
|
a40e44c6d5 | ||
|
|
44af90dc6c | ||
|
|
f01d82ad1e | ||
|
|
ac6fef3073 | ||
|
|
c64c15948b | ||
|
|
e58b68b6fc | ||
|
|
68325866ef | ||
|
|
80198f5fda | ||
|
|
6ac28ad614 | ||
|
|
c021ffddf2 | ||
|
|
f8997446e2 | ||
|
|
9487c02954 | ||
|
|
0188cabbde | ||
|
|
392a6e0137 | ||
|
|
7e62c60748 | ||
|
|
9bff707fb5 | ||
|
|
0d599ef2dc | ||
|
|
e183a0cc63 | ||
|
|
958a3676af | ||
|
|
59a0925d34 | ||
|
|
fbbf58481d | ||
|
|
6356bb1dbb | ||
|
|
d08bce35a3 | ||
|
|
9297448ec8 | ||
|
|
5329326602 | ||
|
|
062fc9dbc0 | ||
|
|
fde8630c5b | ||
|
|
aa0b2c0be4 | ||
|
|
f70eae2f3b | ||
|
|
cf8823fa96 | ||
|
|
5f3d02606c | ||
|
|
aeed6590b7 | ||
|
|
1f18b08655 | ||
|
|
e4dd2a6c07 | ||
|
|
bcb9c73634 | ||
|
|
952eee8d55 | ||
|
|
da469dac18 | ||
|
|
6fac320a05 | ||
|
|
cc7b34e399 | ||
|
|
2d6c26a5d6 | ||
|
|
f1acd46e15 | ||
|
|
c023f7d89b | ||
|
|
8fa45ef9a6 | ||
|
|
8bcc295061 | ||
|
|
1b080012ab | ||
|
|
f6dfca4fdc | ||
|
|
3de655cbd4 | ||
|
|
376f781052 | ||
|
|
827f47809b | ||
|
|
dd11ebf335 | ||
|
|
0c35e196be | ||
|
|
6303847026 | ||
|
|
214692f5b2 | ||
|
|
b7198234de | ||
|
|
7e0ac10fe8 | ||
|
|
f9d154dd82 | ||
|
|
1c6a2fb7dd | ||
|
|
5c272db83b | ||
|
|
602ae01b0b | ||
|
|
cd3fa94b9c | ||
|
|
51c2ea6fcb | ||
|
|
64752de3a8 | ||
|
|
84578a2764 | ||
|
|
126575d22a | ||
|
|
d3af913ec7 | ||
|
|
db4ecee475 | ||
|
|
527c4ece57 | ||
|
|
23b0d4339d | ||
|
|
1478e66dc4 | ||
|
|
3d61604569 | ||
|
|
1b29f65664 | ||
|
|
a229508611 | ||
|
|
d5bd4ef63a | ||
|
|
146332fff3 | ||
|
|
5802858772 | ||
|
|
b39ce9c18a | ||
|
|
a7ab5ae680 | ||
|
|
f2a08853e2 | ||
|
|
23eeb45713 | ||
|
|
6c83a0df09 | ||
|
|
dbee7488e1 | ||
|
|
9ed4591edc |
@@ -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"
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
full_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: full
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
slim_build_push:
|
||||
if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: slim
|
||||
|
||||
@@ -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]
|
||||
@@ -299,31 +249,6 @@ jobs:
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
attach_assets_to_build:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Assets
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
|
||||
- name: Attach Assets
|
||||
id: attach_assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-assets
|
||||
retention-days: 2
|
||||
path: |
|
||||
${{ github.workspace }}/deploy/selfhost/setup.sh
|
||||
${{ github.workspace }}/deploy/selfhost/restore.sh
|
||||
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
|
||||
${{ github.workspace }}/deploy/selfhost/variables.env
|
||||
|
||||
publish_release:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Build Release
|
||||
@@ -337,7 +262,6 @@ jobs:
|
||||
branch_build_push_live,
|
||||
branch_build_push_apiserver,
|
||||
branch_build_push_proxy,
|
||||
attach_assets_to_build,
|
||||
]
|
||||
env:
|
||||
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
|
||||
@@ -348,6 +272,8 @@ jobs:
|
||||
- name: Update Assets
|
||||
run: |
|
||||
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
|
||||
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
|
||||
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -362,6 +288,7 @@ jobs:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ github.workspace }}/deploy/selfhost/setup.sh
|
||||
${{ github.workspace }}/deploy/selfhost/swarm.sh
|
||||
${{ github.workspace }}/deploy/selfhost/restore.sh
|
||||
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
|
||||
${{ github.workspace }}/deploy/selfhost/variables.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
full_build_push:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [branch_build_setup]
|
||||
env:
|
||||
BUILD_TYPE: full
|
||||
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
sync_changes:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
+130
-4
@@ -62,17 +62,143 @@ To ensure consistency throughout the source code, please keep these rules in min
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- Try Plane Cloud and the self hosting platform and give feedback
|
||||
- Add new integrations
|
||||
- Add or update translations
|
||||
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
|
||||
- Share your thoughts and suggestions with us
|
||||
- Help create tutorials and blog posts
|
||||
- Request a feature by submitting a proposal
|
||||
- Report a bug
|
||||
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
|
||||
|
||||
## Contributing to language support
|
||||
This guide is designed to help contributors understand how to add or update translations in the application.
|
||||
|
||||
### Understanding translation structure
|
||||
|
||||
#### File organization
|
||||
Translations are organized by language in the locales directory. Each language has its own folder containing JSON files for translations. Here's how it looks:
|
||||
|
||||
```
|
||||
packages/i18n/src/locales/
|
||||
├── en/
|
||||
│ ├── core.json # Critical translations
|
||||
│ └── translations.json
|
||||
├── fr/
|
||||
│ └── translations.json
|
||||
└── [language]/
|
||||
└── translations.json
|
||||
```
|
||||
#### Nested structure
|
||||
To keep translations organized, we use a nested structure for keys. This makes it easier to manage and locate specific translations. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"issue": {
|
||||
"label": "Work item",
|
||||
"title": {
|
||||
"label": "Work item title"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Translation formatting guide
|
||||
We use [IntlMessageFormat](https://formatjs.github.io/docs/intl-messageformat/) to handle dynamic content, such as variables and pluralization. Here's how to format your translations:
|
||||
|
||||
#### Examples
|
||||
- **Simple variables**
|
||||
```json
|
||||
{
|
||||
"greeting": "Hello, {name}!"
|
||||
}
|
||||
```
|
||||
|
||||
- **Pluralization**
|
||||
```json
|
||||
{
|
||||
"items": "{count, plural, one {Work item} other {Work items}}"
|
||||
}
|
||||
```
|
||||
|
||||
### Contributing guidelines
|
||||
|
||||
#### Updating existing translations
|
||||
1. Locate the key in `locales/<language>/translations.json`.
|
||||
|
||||
2. Update the value while ensuring the key structure remains intact.
|
||||
3. Preserve any existing ICU formats (e.g., variables, pluralization).
|
||||
|
||||
#### Adding new translation keys
|
||||
1. When introducing a new key, ensure it is added to **all** language files, even if translations are not immediately available. Use English as a placeholder if needed.
|
||||
|
||||
2. Keep the nesting structure consistent across all languages.
|
||||
|
||||
3. If the new key requires dynamic content (e.g., variables or pluralization), ensure the ICU format is applied uniformly across all languages.
|
||||
|
||||
### Adding new languages
|
||||
Adding a new language involves several steps to ensure it integrates seamlessly with the project. Follow these instructions carefully:
|
||||
|
||||
1. **Update type definitions**
|
||||
Add the new language to the TLanguage type in the language definitions file:
|
||||
|
||||
```typescript
|
||||
// types/language.ts
|
||||
export type TLanguage = "en" | "fr" | "your-lang";
|
||||
```
|
||||
|
||||
2. **Add language configuration**
|
||||
Include the new language in the list of supported languages:
|
||||
|
||||
```typescript
|
||||
// constants/language.ts
|
||||
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Your Language", value: "your-lang" }
|
||||
];
|
||||
```
|
||||
|
||||
3. **Create translation files**
|
||||
1. Create a new folder for your language under locales (e.g., `locales/your-lang/`).
|
||||
|
||||
2. Add a `translations.json` file inside the folder.
|
||||
|
||||
3. Copy the structure from an existing translation file and translate all keys.
|
||||
|
||||
4. **Update import logic**
|
||||
Modify the language import logic to include your new language:
|
||||
|
||||
```typescript
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "your-lang":
|
||||
return import("../locales/your-lang/translations.json");
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quality checklist
|
||||
Before submitting your contribution, please ensure the following:
|
||||
|
||||
- All translation keys exist in every language file.
|
||||
- Nested structures match across all language files.
|
||||
- ICU message formats are correctly implemented.
|
||||
- All languages load without errors in the application.
|
||||
- Dynamic values and pluralization work as expected.
|
||||
- There are no missing or untranslated keys.
|
||||
|
||||
#### Pro tips
|
||||
- When in doubt, refer to the English translations for context.
|
||||
- Verify pluralization works with different numbers.
|
||||
- Ensure dynamic values (e.g., `{name}`) are correctly interpolated.
|
||||
- Double-check that nested key access paths are accurate.
|
||||
|
||||
Happy translating! 🌍✨
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.24.1",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.1"
|
||||
"version": "0.25.2",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
@@ -79,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)
|
||||
|
||||
@@ -138,47 +140,61 @@ class IssueSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
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 assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# 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,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -194,39 +210,45 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
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 assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
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 assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
@@ -121,8 +121,6 @@ from .exporter import ExporterHistorySerializer
|
||||
|
||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
|
||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||
|
||||
from .favorite import UserFavoriteSerializer
|
||||
|
||||
from .draft import (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
|
||||
|
||||
# Third party frameworks
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class DashboardSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = DeprecatedDashboard
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WidgetSerializer(BaseSerializer):
|
||||
is_visible = serializers.BooleanField(read_only=True)
|
||||
widget_filters = serializers.JSONField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeprecatedWidget
|
||||
fields = ["id", "key", "is_visible", "widget_filters"]
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
@@ -35,6 +36,7 @@ from plane.db.models import (
|
||||
State,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
ProjectMember,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,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)
|
||||
@@ -134,47 +145,64 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
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 assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# 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,
|
||||
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
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -190,39 +218,45 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
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
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
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 assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
@@ -506,6 +540,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
|
||||
"asset",
|
||||
"attributes",
|
||||
# "issue_id",
|
||||
"created_by",
|
||||
"updated_at",
|
||||
"updated_by",
|
||||
"asset_url",
|
||||
|
||||
@@ -2,7 +2,6 @@ from .analytic import urlpatterns as analytic_urls
|
||||
from .api import urlpatterns as api_urls
|
||||
from .asset import urlpatterns as asset_urls
|
||||
from .cycle import urlpatterns as cycle_urls
|
||||
from .dashboard import urlpatterns as dashboard_urls
|
||||
from .estimate import urlpatterns as estimate_urls
|
||||
from .external import urlpatterns as external_urls
|
||||
from .intake import urlpatterns as intake_urls
|
||||
@@ -23,7 +22,6 @@ urlpatterns = [
|
||||
*analytic_urls,
|
||||
*asset_urls,
|
||||
*cycle_urls,
|
||||
*dashboard_urls,
|
||||
*estimate_urls,
|
||||
*external_urls,
|
||||
*intake_urls,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/dashboard/",
|
||||
DashboardEndpoint.as_view(),
|
||||
name="dashboard",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
|
||||
DashboardEndpoint.as_view(),
|
||||
name="dashboard",
|
||||
),
|
||||
path(
|
||||
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
|
||||
WidgetsEndpoint.as_view(),
|
||||
name="widgets",
|
||||
),
|
||||
]
|
||||
@@ -210,8 +210,6 @@ from .webhook.base import (
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
from .notification.base import MarkAllReadNotificationViewSet
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
[self.save_project_cover(asset, project_id) for asset in assets]
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
|
||||
assets.update(issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
assets.update(comment_id=entity_id)
|
||||
# For some cases, the bulk api is called after the comment is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(comment_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
assets.update(page_id=entity_id)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the draft issue is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,812 +0,0 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
JSONField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.serializers import (
|
||||
DashboardSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueSerializer,
|
||||
WidgetSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
DeprecatedDashboard,
|
||||
DeprecatedDashboardWidget,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
FileAsset,
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
DeprecatedWidget,
|
||||
WorkspaceMember,
|
||||
CycleIssue,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
# Module imports
|
||||
from .. import BaseAPIView
|
||||
|
||||
|
||||
def dashboard_overview_stats(self, request, slug):
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
pending_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__lt=timezone.now().date(),
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
created_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
created_by_id=request.user.id,
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
completed_issues_count = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
state__group="completed",
|
||||
)
|
||||
.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"assigned_issues_count": assigned_issues,
|
||||
"pending_issues_count": pending_issues_count,
|
||||
"completed_issues_count": completed_issues_count,
|
||||
"created_issues_count": created_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_assigned_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
assigned_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
(
|
||||
Q(assignees__in=[request.user])
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related(
|
||||
"related_issue"
|
||||
).select_related("issue"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
assigned_issues = assigned_issues.filter(created_by=request.user)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
assigned_issues = assigned_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = assigned_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = assigned_issues.filter(state__group__in=["completed"])[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
completed_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
overdue_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
upcoming_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_created_issues(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issue_type = request.GET.get("issue_type", None)
|
||||
|
||||
# get all the assigned issues
|
||||
created_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
created_by=request.user,
|
||||
)
|
||||
.filter(**filters)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
created_issues = created_issues.annotate(
|
||||
priority_order=Case(
|
||||
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
if issue_type == "pending":
|
||||
pending_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
).count()
|
||||
pending_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "started", "unstarted"]
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(
|
||||
pending_issues, many=True, expand=self.expand
|
||||
).data,
|
||||
"count": pending_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "completed":
|
||||
completed_issues_count = created_issues.filter(
|
||||
state__group__in=["completed"]
|
||||
).count()
|
||||
completed_issues = created_issues.filter(state__group__in=["completed"])[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(completed_issues, many=True).data,
|
||||
"count": completed_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(overdue_issues, many=True).data,
|
||||
"count": overdue_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
"issues": IssueSerializer(upcoming_issues, many=True).data,
|
||||
"count": upcoming_issues_count,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid issue type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_issues_by_state_groups(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_state_groups = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("state__group")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default state
|
||||
all_groups = {state: 0 for state in state_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_state_groups:
|
||||
all_groups[entry["state__group"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"state": group, "count": count} for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_issues_by_priority(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
extra_filters = {}
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role=5, is_active=True
|
||||
).exists():
|
||||
extra_filters = {"created_by": request.user}
|
||||
|
||||
issues_by_priority = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__member=request.user,
|
||||
assignees__in=[request.user],
|
||||
)
|
||||
.filter(**filters, **extra_filters)
|
||||
.values("priority")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
|
||||
# default priority
|
||||
all_groups = {priority: 0 for priority in priority_order}
|
||||
|
||||
# Update counts for existing groups
|
||||
for entry in issues_by_priority:
|
||||
all_groups[entry["priority"]] = entry["count"]
|
||||
|
||||
# Prepare output including all groups with their counts
|
||||
output_data = [
|
||||
{"priority": group, "count": count} for group, count in all_groups.items()
|
||||
]
|
||||
|
||||
return Response(output_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_recent_activity(self, request, slug):
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
actor=request.user,
|
||||
).select_related("actor", "workspace", "issue", "project")[:8]
|
||||
|
||||
return Response(
|
||||
IssueActivitySerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
def dashboard_recent_projects(self, request, slug):
|
||||
project_ids = (
|
||||
IssueActivity.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
actor=request.user,
|
||||
)
|
||||
.values_list("project_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Extract project IDs from the recent projects
|
||||
unique_project_ids = set(project_id for project_id in project_ids)
|
||||
|
||||
# Fetch additional projects only if needed
|
||||
if len(unique_project_ids) < 4:
|
||||
additional_projects = Project.objects.filter(
|
||||
project_projectmember__member=request.user,
|
||||
project_projectmember__is_active=True,
|
||||
archived_at__isnull=True,
|
||||
workspace__slug=slug,
|
||||
).exclude(id__in=unique_project_ids)
|
||||
|
||||
# Append additional project IDs to the existing list
|
||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
||||
|
||||
return Response(list(unique_project_ids)[:4], status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def dashboard_recent_collaborators(self, request, slug):
|
||||
project_members_with_activities = (
|
||||
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True)
|
||||
.annotate(
|
||||
active_issue_count=Count(
|
||||
Case(
|
||||
When(
|
||||
member__issue_assignee__issue__state__group__in=[
|
||||
"unstarted",
|
||||
"started",
|
||||
],
|
||||
member__issue_assignee__issue__workspace__slug=slug,
|
||||
member__issue_assignee__issue__project__project_projectmember__member=request.user,
|
||||
member__issue_assignee__issue__project__project_projectmember__is_active=True,
|
||||
then=F("member__issue_assignee__issue__id"),
|
||||
),
|
||||
distinct=True,
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
user_id=F("member_id"),
|
||||
)
|
||||
.values("user_id", "active_issue_count")
|
||||
.order_by("-active_issue_count")
|
||||
.distinct()
|
||||
)
|
||||
return Response((project_members_with_activities), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DashboardEndpoint(BaseAPIView):
|
||||
def create(self, request, slug):
|
||||
serializer = DashboardSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
serializer = DashboardSerializer(data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get(self, request, slug, dashboard_id=None):
|
||||
if not dashboard_id:
|
||||
dashboard_type = request.GET.get("dashboard_type", None)
|
||||
if dashboard_type == "home":
|
||||
dashboard, created = DeprecatedDashboard.objects.get_or_create(
|
||||
type_identifier=dashboard_type,
|
||||
owned_by=request.user,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
if created:
|
||||
widgets_to_fetch = [
|
||||
"overview_stats",
|
||||
"assigned_issues",
|
||||
"created_issues",
|
||||
"issues_by_state_groups",
|
||||
"issues_by_priority",
|
||||
"recent_activity",
|
||||
"recent_projects",
|
||||
"recent_collaborators",
|
||||
]
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
for widget_key in widgets_to_fetch:
|
||||
widget = DeprecatedWidget.objects.filter(
|
||||
key=widget_key
|
||||
).values_list("id", flat=True)
|
||||
if widget:
|
||||
updated_dashboard_widgets.append(
|
||||
DeprecatedDashboardWidget(
|
||||
widget_id=widget, dashboard_id=dashboard.id
|
||||
)
|
||||
)
|
||||
|
||||
DeprecatedDashboardWidget.objects.bulk_create(
|
||||
updated_dashboard_widgets, batch_size=100
|
||||
)
|
||||
|
||||
widgets = (
|
||||
DeprecatedWidget.objects.annotate(
|
||||
is_visible=Exists(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
is_visible=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
dashboard_filters=Subquery(
|
||||
DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=OuterRef("pk"),
|
||||
dashboard_id=dashboard.id,
|
||||
filters__isnull=False,
|
||||
)
|
||||
.exclude(filters={})
|
||||
.values("filters")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
widget_filters=Case(
|
||||
When(
|
||||
dashboard_filters__isnull=False,
|
||||
then=F("dashboard_filters"),
|
||||
),
|
||||
default=F("filters"),
|
||||
output_field=JSONField(),
|
||||
)
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"dashboard": DashboardSerializer(dashboard).data,
|
||||
"widgets": WidgetSerializer(widgets, many=True).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return Response(
|
||||
{"error": "Please specify a valid dashboard type"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
widget_key = request.GET.get("widget_key", "overview_stats")
|
||||
|
||||
WIDGETS_MAPPER = {
|
||||
"overview_stats": dashboard_overview_stats,
|
||||
"assigned_issues": dashboard_assigned_issues,
|
||||
"created_issues": dashboard_created_issues,
|
||||
"issues_by_state_groups": dashboard_issues_by_state_groups,
|
||||
"issues_by_priority": dashboard_issues_by_priority,
|
||||
"recent_activity": dashboard_recent_activity,
|
||||
"recent_projects": dashboard_recent_projects,
|
||||
"recent_collaborators": dashboard_recent_collaborators,
|
||||
}
|
||||
|
||||
func = WIDGETS_MAPPER.get(widget_key)
|
||||
if func is not None:
|
||||
response = func(self, request=request, slug=slug)
|
||||
if isinstance(response, Response):
|
||||
return response
|
||||
|
||||
return Response(
|
||||
{"error": "Please specify a valid widget key"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class WidgetsEndpoint(BaseAPIView):
|
||||
def patch(self, request, dashboard_id, widget_id):
|
||||
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
|
||||
widget_id=widget_id, dashboard_id=dashboard_id
|
||||
).first()
|
||||
dashboard_widget.is_visible = request.data.get(
|
||||
"is_visible", dashboard_widget.is_visible
|
||||
)
|
||||
dashboard_widget.sort_order = request.data.get(
|
||||
"sort_order", dashboard_widget.sort_order
|
||||
)
|
||||
dashboard_widget.filters = request.data.get("filters", dashboard_widget.filters)
|
||||
dashboard_widget.save()
|
||||
return Response({"message": "successfully updated"}, status=status.HTTP_200_OK)
|
||||
+8
-6
@@ -3,7 +3,7 @@ import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Third party import
|
||||
import litellm
|
||||
from openai import OpenAI
|
||||
import requests
|
||||
|
||||
from rest_framework import status
|
||||
@@ -116,12 +116,14 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
if provider.lower() == "gemini":
|
||||
model = f"gemini/{model}"
|
||||
|
||||
response = litellm.completion(
|
||||
client = OpenAI(api_key=api_key)
|
||||
chat_completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
api_key=api_key,
|
||||
messages=[
|
||||
{"role": "user", "content": final_text}
|
||||
]
|
||||
)
|
||||
text = response.choices[0].message.content.strip()
|
||||
text = chat_completion.choices[0].message.content
|
||||
return text, None
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
@@ -175,7 +177,7 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
|
||||
@@ -174,14 +174,19 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
intake_id = Intake.objects.filter(
|
||||
intake = Intake.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
if not intake:
|
||||
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__")
|
||||
intake_issue = (
|
||||
IntakeIssue.objects.filter(
|
||||
intake_id=intake_id.id, project_id=project_id, **filters
|
||||
intake_id=intake.id, project_id=project_id, **filters
|
||||
)
|
||||
.select_related("issue")
|
||||
.prefetch_related("issue__labels")
|
||||
@@ -382,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():
|
||||
|
||||
@@ -547,7 +547,7 @@ class IssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
@@ -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,13 +1116,24 @@ 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()):
|
||||
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)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{"error": "Invalid issue identifier"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -1223,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,
|
||||
)
|
||||
@@ -1240,7 +1252,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.utils import timezone
|
||||
from django.db.models import Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
|
||||
try:
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
actor_id=request.user.id,
|
||||
comment_id=comment_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Reaction already exists for the user"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
|
||||
@@ -55,6 +55,20 @@ class LabelViewSet(BaseViewSet):
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Check if the label name is unique within the project
|
||||
if (
|
||||
"name" in request.data
|
||||
and Label.objects.filter(
|
||||
project_id=kwargs["project_id"], name=request.data["name"]
|
||||
)
|
||||
.exclude(pk=kwargs["pk"])
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@@ -74,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
|
||||
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
|
||||
@@ -280,7 +280,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.first().module.name}
|
||||
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Django modules
|
||||
from django.db.models import Q
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
@@ -31,16 +32,37 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
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(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def patch(self, request, slug, favorite_id):
|
||||
|
||||
@@ -100,8 +100,20 @@ class ResetPasswordEndpoint(View):
|
||||
def post(self, request, uidb64, token):
|
||||
try:
|
||||
# Decode the id from the uidb64
|
||||
id = smart_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(id=id)
|
||||
try:
|
||||
id = smart_str(urlsafe_base64_decode(uidb64))
|
||||
user = User.objects.get(id=id)
|
||||
except (ValueError, User.DoesNotExist):
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
|
||||
error_message="INVALID_PASSWORD_TOKEN",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
"accounts/reset-password?" + urlencode(params),
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# check if the token is valid for the user
|
||||
if not PasswordResetTokenGenerator().check_token(user, token):
|
||||
|
||||
@@ -9,10 +9,10 @@ from celery import shared_task
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -32,6 +32,7 @@ from plane.settings.redis import redis_instance
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.bgtasks.webhook_task import webhook_activity
|
||||
from plane.utils.issue_relation_mapper import get_inverse_relation
|
||||
from plane.utils.valid_uuid import is_valid_uuid
|
||||
|
||||
|
||||
# Track Changes in name
|
||||
@@ -790,14 +791,15 @@ def create_cycle_issue_activity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_cycle.name,
|
||||
new_value=new_cycle.name,
|
||||
old_value=old_cycle.name if old_cycle else "",
|
||||
new_value=new_cycle.name if new_cycle else "",
|
||||
field="cycles",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
|
||||
to {new_cycle.name if new_cycle else ""}""",
|
||||
old_identifier=old_cycle.id if old_cycle else None,
|
||||
new_identifier=new_cycle.id if new_cycle else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -851,7 +853,7 @@ def delete_cycle_issue_activity(
|
||||
issues = requested_data.get("issues")
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
if current_issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
@@ -893,11 +895,11 @@ def create_module_issue_activity(
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
new_value=module.name if module else "",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
comment=f"added module {module.name if module else ''}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1413,7 +1415,7 @@ def delete_issue_relation_activity(
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f'deleted {requested_data.get("relation_type")} relation',
|
||||
comment=f"deleted {requested_data.get('relation_type')} relation",
|
||||
old_identifier=requested_data.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1567,6 +1569,10 @@ def issue_activity(
|
||||
try:
|
||||
issue_activities = []
|
||||
|
||||
# check if project_id is valid
|
||||
if not is_valid_uuid(str(project_id)):
|
||||
return
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
|
||||
).first()
|
||||
|
||||
if recent_visited:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
# Check if the database is available
|
||||
try:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
except DatabaseError:
|
||||
pass
|
||||
else:
|
||||
recent_visited_count = UserRecentVisit.objects.filter(
|
||||
user_id=user_id, workspace_id=workspace.id
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.2.18 on 2025-02-25 15:48
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0091_issuecomment_edited_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="deprecateddashboardwidget",
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="created_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="dashboard",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="updated_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="deprecateddashboardwidget",
|
||||
name="widget",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedDashboard",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedDashboardWidget",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DeprecatedWidget",
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,6 @@ from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleIssue, CycleUserProperties
|
||||
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
|
||||
from .deploy_board import DeployBoard
|
||||
from .draft import (
|
||||
DraftIssue,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from ..mixins import TimeAuditModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class DeprecatedDashboard(BaseModel):
|
||||
DASHBOARD_CHOICES = (
|
||||
("workspace", "Workspace"),
|
||||
("project", "Project"),
|
||||
("home", "Home"),
|
||||
("team", "Team"),
|
||||
("user", "User"),
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
identifier = models.UUIDField(null=True)
|
||||
owned_by = models.ForeignKey(
|
||||
"db.User", on_delete=models.CASCADE, related_name="dashboards"
|
||||
)
|
||||
is_default = models.BooleanField(default=False)
|
||||
type_identifier = models.CharField(
|
||||
max_length=30,
|
||||
choices=DASHBOARD_CHOICES,
|
||||
verbose_name="Dashboard Type",
|
||||
default="home",
|
||||
)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the dashboard"""
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "DeprecatedDashboard"
|
||||
verbose_name_plural = "DeprecatedDashboards"
|
||||
db_table = "deprecated_dashboards"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class DeprecatedWidget(TimeAuditModel):
|
||||
id = models.UUIDField(
|
||||
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
filters = models.JSONField(default=dict)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the widget"""
|
||||
return f"{self.key}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "DeprecatedWidget"
|
||||
verbose_name_plural = "DeprecatedWidgets"
|
||||
db_table = "deprecated_widgets"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class DeprecatedDashboardWidget(BaseModel):
|
||||
widget = models.ForeignKey(
|
||||
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
dashboard = models.ForeignKey(
|
||||
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
|
||||
)
|
||||
is_visible = models.BooleanField(default=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
filters = models.JSONField(default=dict)
|
||||
properties = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the dashboard"""
|
||||
return f"{self.dashboard.name} {self.widget.key}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("widget", "dashboard", "deleted_at")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["widget", "dashboard"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Deprecated Dashboard Widget"
|
||||
verbose_name_plural = "Deprecated Dashboard Widgets"
|
||||
db_table = "deprecated_dashboard_widgets"
|
||||
ordering = ("-created_at",)
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
)
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -77,7 +77,7 @@ def convert_to_utc(
|
||||
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
if utc_datetime.date() == current_datetime_in_utc.date():
|
||||
if localized_datetime.date() == current_datetime_in_project_tz.date():
|
||||
return current_datetime_in_utc
|
||||
|
||||
return utc_datetime
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import uuid
|
||||
|
||||
def is_valid_uuid(uuid_str):
|
||||
try:
|
||||
uuid.UUID(uuid_str, version=4)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -1,10 +1,10 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.18
|
||||
Django==4.2.20
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
# postgres
|
||||
psycopg==3.1.18
|
||||
psycopg-binary==3.1.18
|
||||
psycopg-c==3.1.18
|
||||
@@ -37,7 +37,7 @@ uvicorn==0.29.0
|
||||
# sockets
|
||||
channels==4.1.0
|
||||
# ai
|
||||
litellm==1.51.0
|
||||
openai==1.63.2
|
||||
# slack
|
||||
slack-sdk==3.27.1
|
||||
# apm
|
||||
@@ -66,4 +66,4 @@ PyJWT==2.8.0
|
||||
opentelemetry-api==1.28.1
|
||||
opentelemetry-sdk==1.28.1
|
||||
opentelemetry-instrumentation-django==0.49b1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
# debug toolbar
|
||||
django-debug-toolbar==4.3.0
|
||||
# formatter
|
||||
ruff==0.4.2
|
||||
ruff==0.9.7
|
||||
|
||||
+136
-14
@@ -55,18 +55,30 @@ Installing plane is a very easy and minimal step process.
|
||||
- User context used must have access to docker services. In most cases, use sudo su to switch as root user
|
||||
- Use the terminal (or gitbash) window to run all the future steps
|
||||
|
||||
### Downloading Latest Stable Release
|
||||
### Downloading Latest Release
|
||||
|
||||
```
|
||||
mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
```
|
||||
|
||||
#### For *Docker Compose* based setup
|
||||
|
||||
```
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
#### For *Docker Swarm* based setup
|
||||
|
||||
```
|
||||
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/swarm.sh
|
||||
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Proceed with setup
|
||||
@@ -77,8 +89,9 @@ Lets get started by running the `./setup.sh` command.
|
||||
|
||||
This will prompt you with the below options.
|
||||
|
||||
#### Docker Compose
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
Select an Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
2) Start
|
||||
3) Stop
|
||||
@@ -87,17 +100,42 @@ Select a Action you want to perform:
|
||||
6) View Logs
|
||||
7) Backup Data
|
||||
8) Exit
|
||||
|
||||
Action [2]: 1
|
||||
```
|
||||
|
||||
For the 1st time setup, type "1" as action input.
|
||||
|
||||
This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that
|
||||
This will create a folder `plane-app` and will download 2 files inside that
|
||||
|
||||
- `docker-compose.yaml`
|
||||
- `plane.env`
|
||||
|
||||
Again the `options [1-8]` will be popped up and this time hit `8` to exit.
|
||||
Again the `options [1-8]` will be popped up, and this time hit `8` to exit.
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
```bash
|
||||
Select an Action you want to perform:
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 1
|
||||
```
|
||||
|
||||
For the 1st time setup, type "1" as action input.
|
||||
|
||||
This will create a create a folder `plane-app` and will download 2 files inside that
|
||||
|
||||
- `docker-compose.yaml`
|
||||
- `plane.env`
|
||||
|
||||
Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +154,7 @@ There are many other settings you can play with, but we suggest you configure `E
|
||||
|
||||
---
|
||||
|
||||
### Continue with setup - Start Server
|
||||
### Continue with setup - Start Server (Docker Compose)
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices
|
||||
|
||||
@@ -147,9 +185,11 @@ You have successfully self hosted `Plane` instance. Access the application by go
|
||||
|
||||
---
|
||||
|
||||
### Stopping the Server
|
||||
### Stopping the Server / Remove Stack
|
||||
|
||||
In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that.
|
||||
In case you want to make changes to `plane.env` variables, we suggest you to stop the services before doing that.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices
|
||||
|
||||
@@ -171,14 +211,34 @@ If all goes well, you must see something like this
|
||||
|
||||

|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to stop the sevices
|
||||
|
||||
```bash
|
||||
Select an Action you want to perform:
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 2
|
||||
```
|
||||
|
||||
If all goes well, you will see the confirmation from docker cli
|
||||
|
||||
---
|
||||
|
||||
### Restarting the Server
|
||||
### Restarting the Server / Redeploy Stack
|
||||
|
||||
In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option.
|
||||
In case you want to make changes to `plane.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with `RESTART` / `REDEPLOY` option.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices
|
||||
|
||||
#### Docker Compose
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
@@ -197,14 +257,32 @@ If all goes well, you must see something like this
|
||||
|
||||

|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 4
|
||||
```
|
||||
|
||||
If all goes well, you will see the confirmation from docker cli
|
||||
|
||||
---
|
||||
|
||||
### Upgrading Plane Version
|
||||
### Upgrading Plane Version
|
||||
|
||||
It is always advised to keep Plane up to date with the latest release.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
1) Install (x86_64)
|
||||
@@ -231,13 +309,41 @@ Once done, choose `8` to exit from prompt.
|
||||
|
||||
Once done with making changes in `plane.env` file, jump on to `Start Server`
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 5
|
||||
```
|
||||
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
|
||||
|
||||
Once done, choose `7` to exit from prompt.
|
||||
|
||||
> It is very important for you to validate the `plane.env` for the new changes.
|
||||
|
||||
Once done with making changes in `plane.env` file, jump on to `Redeploy Stack`
|
||||
|
||||
---
|
||||
|
||||
### View Logs
|
||||
|
||||
There would a time when you might want to check what is happening inside the API, Worker or any other container.
|
||||
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs.
|
||||
Lets again run the `./setup.sh` command. You will again be prompted with the below options.
|
||||
|
||||
This time select `6` to view logs.
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```bash
|
||||
Select a Action you want to perform:
|
||||
@@ -253,7 +359,22 @@ Select a Action you want to perform:
|
||||
Action [2]: 6
|
||||
```
|
||||
|
||||
#### Docker Swarm
|
||||
|
||||
|
||||
```bash
|
||||
1) Deploy Stack
|
||||
2) Remove Stack
|
||||
3) View Stack Status
|
||||
4) Redeploy Stack
|
||||
5) Upgrade
|
||||
6) View Logs
|
||||
7) Exit
|
||||
|
||||
Action [3]: 6
|
||||
```
|
||||
|
||||
#### Service Menu Options for Logs
|
||||
This will further open sub-menu with list of services
|
||||
```bash
|
||||
Select a Service you want to view the logs for:
|
||||
@@ -267,9 +388,10 @@ Select a Service you want to view the logs for:
|
||||
8) Redis
|
||||
9) Postgres
|
||||
10) Minio
|
||||
11) RabbitMQ
|
||||
0) Back to Main Menu
|
||||
|
||||
Service:
|
||||
Service: 3
|
||||
```
|
||||
|
||||
Select any of the service to view the logs e.g. `3`. Expect something similar to this
|
||||
@@ -323,7 +445,7 @@ Similarly, you can view the logs of other services.
|
||||
|
||||
---
|
||||
|
||||
### Backup Data
|
||||
### Backup Data (Docker Compose)
|
||||
|
||||
There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives.
|
||||
|
||||
@@ -355,7 +477,7 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
|
||||
|
||||
---
|
||||
|
||||
### Restore Data
|
||||
### Restore Data (Docker Compose)
|
||||
|
||||
When you want to restore the previously backed-up data, follow the instructions below.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ x-redis-env: &redis-env
|
||||
x-minio-env: &minio-env
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
|
||||
|
||||
|
||||
x-aws-s3-env: &aws-s3-env
|
||||
AWS_REGION: ${AWS_REGION:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
|
||||
@@ -28,8 +28,7 @@ x-proxy-env: &proxy-env
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
x-mq-env: &mq-env
|
||||
# RabbitMQ Settings
|
||||
x-mq-env: &mq-env # RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
|
||||
@@ -50,33 +49,29 @@ x-app-env: &app-env
|
||||
USE_MINIO: ${USE_MINIO:-1}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
|
||||
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
|
||||
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
|
||||
SPACE_BASE_URL: ${SPACE_BASE_URL}
|
||||
APP_BASE_URL: ${APP_BASE_URL}
|
||||
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:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node web/server.js web
|
||||
deploy:
|
||||
replicas: ${WEB_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
|
||||
space:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node space/server.js space
|
||||
deploy:
|
||||
replicas: ${SPACE_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
@@ -84,42 +79,39 @@ services:
|
||||
|
||||
admin:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node admin/server.js admin
|
||||
deploy:
|
||||
replicas: ${ADMIN_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
|
||||
live:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: node live/dist/server.js live
|
||||
environment:
|
||||
<<: [ *live-env ]
|
||||
<<: [*live-env]
|
||||
deploy:
|
||||
replicas: ${LIVE_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
|
||||
api:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-api.sh
|
||||
deploy:
|
||||
replicas: ${API_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_api:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -127,14 +119,15 @@ services:
|
||||
|
||||
worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-worker.sh
|
||||
deploy:
|
||||
replicas: ${WORKER_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -143,14 +136,15 @@ services:
|
||||
|
||||
beat-worker:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ./bin/docker-entrypoint-beat.sh
|
||||
deploy:
|
||||
replicas: ${BEAT_WORKER_REPLICAS:-1}
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_beat-worker:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- api
|
||||
- plane-db
|
||||
@@ -159,23 +153,27 @@ services:
|
||||
|
||||
migrator:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: "no"
|
||||
command: ./bin/docker-entrypoint-migrator.sh
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- logs_migrator:/code/plane/logs
|
||||
environment:
|
||||
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
|
||||
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
# Comment this if you already have a database running
|
||||
plane-db:
|
||||
image: postgres:15.7-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: postgres -c 'max_connections=1000'
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *db-env
|
||||
volumes:
|
||||
@@ -183,24 +181,32 @@ services:
|
||||
|
||||
plane-redis:
|
||||
image: valkey/valkey:7.2.5-alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
|
||||
plane-mq:
|
||||
image: rabbitmq:3.13.6-management-alpine
|
||||
restart: always
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *mq-env
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
|
||||
# Comment this if you using any external s3 compatible storage
|
||||
plane-minio:
|
||||
image: minio/minio:latest
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: server /export --console-address ":9090"
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *minio-env
|
||||
volumes:
|
||||
@@ -209,13 +215,17 @@ services:
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
|
||||
platform: ${DOCKER_PLATFORM:-}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
- target: 80
|
||||
published: ${NGINX_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
@@ -224,7 +234,6 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
||||
uploads:
|
||||
logs_api:
|
||||
logs_worker:
|
||||
|
||||
@@ -457,12 +457,13 @@ function viewLogs(){
|
||||
echo " 8) Redis"
|
||||
echo " 9) Postgres"
|
||||
echo " 10) Minio"
|
||||
echo " 11) RabbitMQ"
|
||||
echo " 0) Back to Main Menu"
|
||||
echo
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
|
||||
echo "Invalid selection. Please enter a number between 1 and 11."
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
|
||||
echo "Invalid selection. Please enter a number between 0 and 11."
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
done
|
||||
|
||||
@@ -481,6 +482,7 @@ function viewLogs(){
|
||||
8) viewSpecificLogs "plane-redis";;
|
||||
9) viewSpecificLogs "plane-db";;
|
||||
10) viewSpecificLogs "plane-minio";;
|
||||
11) viewSpecificLogs "plane-mq";;
|
||||
0) askForAction;;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
@@ -499,6 +501,7 @@ function viewLogs(){
|
||||
redis) viewSpecificLogs "plane-redis";;
|
||||
postgres) viewSpecificLogs "plane-db";;
|
||||
minio) viewSpecificLogs "plane-minio";;
|
||||
rabbitmq) viewSpecificLogs "plane-mq";;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
else
|
||||
|
||||
Executable
+612
@@ -0,0 +1,612 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=${BRANCH:-master}
|
||||
SERVICE_FOLDER=plane-app
|
||||
SCRIPT_DIR=$PWD
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
export APP_RELEASE="stable"
|
||||
export DOCKERHUB_USER=makeplane
|
||||
|
||||
export GH_REPO=makeplane/plane
|
||||
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
|
||||
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
|
||||
|
||||
OS_NAME=$(uname)
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p $PLANE_INSTALL_DIR/archive
|
||||
|
||||
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yml
|
||||
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
|
||||
|
||||
function print_header() {
|
||||
clear
|
||||
|
||||
cat <<"EOF"
|
||||
--------------------------------------------
|
||||
____ _ /////////
|
||||
| _ \| | __ _ _ __ ___ /////////
|
||||
| |_) | |/ _` | '_ \ / _ \ ///// /////
|
||||
| __/| | (_| | | | | __/ ///// /////
|
||||
|_| |_|\__,_|_| |_|\___| ////
|
||||
////
|
||||
--------------------------------------------
|
||||
Project management tool from the future
|
||||
--------------------------------------------
|
||||
EOF
|
||||
}
|
||||
|
||||
function checkLatestRelease(){
|
||||
echo "Checking for the latest release..." >&2
|
||||
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
|
||||
if [ -z "$latest_release" ]; then
|
||||
echo "Failed to check for the latest release. Exiting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo $latest_release
|
||||
}
|
||||
|
||||
# Function to read stack name from env file
|
||||
function readStackName() {
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
local saved_stack_name=$(grep "^STACK_NAME=" "$DOCKER_ENV_PATH" | cut -d'=' -f2)
|
||||
if [ -n "$saved_stack_name" ]; then
|
||||
stack_name=$saved_stack_name
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to get stack name (either from env or user input)
|
||||
function getStackName() {
|
||||
read -p "Enter stack name [plane]: " input_stack_name
|
||||
if [ -z "$input_stack_name" ]; then
|
||||
input_stack_name="plane"
|
||||
fi
|
||||
stack_name=$input_stack_name
|
||||
updateEnvFile "STACK_NAME" "$stack_name" "$DOCKER_ENV_PATH"
|
||||
echo "Using stack name: $stack_name"
|
||||
}
|
||||
|
||||
function syncEnvFile(){
|
||||
echo "Syncing environment variables..." >&2
|
||||
if [ -f "$PLANE_INSTALL_DIR/plane.env.bak" ]; then
|
||||
# READ keys of plane.env and update the values from plane.env.bak
|
||||
while IFS= read -r line
|
||||
do
|
||||
# ignore if the line is empty or starts with #
|
||||
if [ -z "$line" ] || [[ $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
key=$(echo "$line" | cut -d'=' -f1)
|
||||
value=$(getEnvValue "$key" "$PLANE_INSTALL_DIR/plane.env.bak")
|
||||
if [ -n "$value" ]; then
|
||||
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
done < "$DOCKER_ENV_PATH"
|
||||
|
||||
value=$(getEnvValue "STACK_NAME" "$PLANE_INSTALL_DIR/plane.env.bak")
|
||||
if [ -n "$value" ]; then
|
||||
updateEnvFile "STACK_NAME" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
fi
|
||||
echo "Environment variables synced successfully" >&2
|
||||
rm -f $PLANE_INSTALL_DIR/plane.env.bak
|
||||
}
|
||||
|
||||
function getEnvValue() {
|
||||
local key=$1
|
||||
local file=$2
|
||||
|
||||
if [ -z "$key" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -eq 0 ]; then
|
||||
local value
|
||||
value=$(grep "^$key=" "$file" | cut -d'=' -f2)
|
||||
echo "$value"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function updateEnvFile() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
local file=$3
|
||||
|
||||
if [ -z "$key" ] || [ -z "$value" ] || [ -z "$file" ]; then
|
||||
echo "Invalid arguments supplied"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
# check if key exists in the file
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
else
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "File not found: $file"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function download() {
|
||||
cd $SCRIPT_DIR || exit 1
|
||||
TS=$(date +%s)
|
||||
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yml" ]
|
||||
then
|
||||
mv $PLANE_INSTALL_DIR/docker-compose.yml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml
|
||||
fi
|
||||
|
||||
echo $RELEASE_DOWNLOAD_URL
|
||||
echo $FALLBACK_DOWNLOAD_URL
|
||||
echo $APP_RELEASE
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
else
|
||||
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
# Fallback to download from the raw github url
|
||||
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
|
||||
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
|
||||
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$STATUS" -eq 200 ]; then
|
||||
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
|
||||
else
|
||||
echo "Failed to download variables.env. HTTP Status: $STATUS"
|
||||
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
|
||||
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DOCKER_ENV_PATH" ];
|
||||
then
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/archive/$TS.env"
|
||||
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/plane.env.bak"
|
||||
fi
|
||||
|
||||
mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH
|
||||
|
||||
syncEnvFile
|
||||
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
|
||||
}
|
||||
function deployStack() {
|
||||
# Check if docker compose file and env file exist
|
||||
if [ ! -f "$DOCKER_FILE_PATH" ] || [ ! -f "$DOCKER_ENV_PATH" ]; then
|
||||
echo "Configuration files not found"
|
||||
echo "Downloading it now......"
|
||||
APP_RELEASE=$(checkLatestRelease)
|
||||
download
|
||||
fi
|
||||
if [ -z "$stack_name" ]; then
|
||||
getStackName
|
||||
fi
|
||||
echo "Starting ${stack_name} stack..."
|
||||
|
||||
# Pull envs
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
set -o allexport; source $DOCKER_ENV_PATH; set +o allexport;
|
||||
else
|
||||
echo "Environment file not found: $DOCKER_ENV_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Deploy the stack
|
||||
docker stack deploy -c $DOCKER_FILE_PATH $stack_name
|
||||
|
||||
echo "Waiting for services to be deployed..."
|
||||
sleep 10
|
||||
|
||||
# Check migrator service
|
||||
local migrator_service=$(docker service ls --filter name=${stack_name}_migrator -q)
|
||||
if [ -n "$migrator_service" ]; then
|
||||
echo ">> Waiting for Data Migration to finish"
|
||||
while docker service ls --filter name=${stack_name}_migrator | grep -q "running"; do
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Get the most recent container for the migrator service
|
||||
local migrator_container=$(docker ps -a --filter name=${stack_name}_migrator --latest -q)
|
||||
|
||||
if [ -n "$migrator_container" ]; then
|
||||
# Get the exit code of the container
|
||||
local exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container)
|
||||
|
||||
if [ "$exit_code" != "0" ]; then
|
||||
echo "Server failed to start ❌"
|
||||
echo "Migration failed with exit code: $exit_code"
|
||||
echo "Please check the logs for the 'migrator' service and resolve the issue(s)."
|
||||
echo "Stop the services by running the command: ./swarm.sh stop"
|
||||
exit 1
|
||||
else
|
||||
echo " Data Migration completed successfully ✅"
|
||||
fi
|
||||
else
|
||||
echo "Warning: Could not find migrator container to check exit status"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check API service
|
||||
local api_service=$(docker service ls --filter name=${stack_name}_api -q)
|
||||
while docker service ls --filter name=${stack_name}_api | grep -q "running"; do
|
||||
local running_container=$(docker ps --filter "name=${stack_name}_api" --filter "status=running" -q)
|
||||
if [ -n "$running_container" ]; then
|
||||
if docker container logs $running_container 2>/dev/null | grep -q "Application Startup Complete"; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$api_service" ]; then
|
||||
echo "Plane Server failed to start ❌"
|
||||
echo "Please check the logs for the 'api' service and resolve the issue(s)."
|
||||
echo "Stop the services by running the command: ./swarm.sh stop"
|
||||
exit 1
|
||||
fi
|
||||
echo " Plane Server started successfully ✅"
|
||||
echo ""
|
||||
echo " You can access the application at $WEB_URL"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function removeStack() {
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Removing ${stack_name} stack..."
|
||||
docker stack rm "$stack_name"
|
||||
echo "Waiting for services to be removed..."
|
||||
while docker stack ls | grep -q "$stack_name"; do
|
||||
sleep 1
|
||||
done
|
||||
sleep 20
|
||||
echo "Services stopped successfully ✅"
|
||||
}
|
||||
|
||||
function viewStatus() {
|
||||
echo "Checking status of ${stack_name} stack..."
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
docker stack ps "$stack_name"
|
||||
}
|
||||
|
||||
function redeployStack() {
|
||||
removeStack
|
||||
echo "ReDeploying ${stack_name} stack..."
|
||||
deployStack
|
||||
}
|
||||
|
||||
function upgrade() {
|
||||
|
||||
echo "Checking status of ${stack_name} stack..."
|
||||
if [ -z "$stack_name" ]; then
|
||||
echo "Stack name not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local latest_release=$(checkLatestRelease)
|
||||
|
||||
echo ""
|
||||
echo "Current release: $APP_RELEASE"
|
||||
|
||||
if [ "$latest_release" == "$APP_RELEASE" ]; then
|
||||
echo ""
|
||||
echo "You are already using the latest release"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Latest release: $latest_release"
|
||||
echo ""
|
||||
|
||||
# Check for confirmation to upgrade
|
||||
echo "Do you want to upgrade to the latest release ($latest_release)?"
|
||||
read -p "Continue? [y/N]: " confirm
|
||||
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export APP_RELEASE=$latest_release
|
||||
|
||||
# check if stack exists
|
||||
echo "Upgrading ${stack_name} stack..."
|
||||
|
||||
# check env file and take backup
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
cp "$DOCKER_ENV_PATH" "${DOCKER_ENV_PATH}.bak"
|
||||
fi
|
||||
|
||||
download
|
||||
redeployStack
|
||||
}
|
||||
|
||||
function viewSpecificLogs() {
|
||||
local service=$1
|
||||
|
||||
# Input validation
|
||||
if [ -z "$service" ]; then
|
||||
echo "Error: Please specify a service name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Main loop for service logs
|
||||
while true; do
|
||||
# Get all running containers for the service
|
||||
local running_containers=$(docker ps --filter "name=${stack_name}_${service}" --filter "status=running" -q)
|
||||
|
||||
# If no running containers found, try service logs
|
||||
if [ -z "$running_containers" ]; then
|
||||
echo "No running containers found for ${stack_name}_${service}, checking service logs..."
|
||||
if docker service inspect ${stack_name}_${service} >/dev/null 2>&1; then
|
||||
echo "Press Ctrl+C or 'q' to exit logs"
|
||||
docker service logs ${stack_name}_${service} -f
|
||||
break
|
||||
else
|
||||
echo "Error: No running containers or services found for ${stack_name}_${service}"
|
||||
return 1
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
# If multiple containers are running, let user choose
|
||||
if [ $(echo "$running_containers" | grep -v '^$' | wc -l) -gt 1 ]; then
|
||||
clear
|
||||
echo "Multiple containers found for ${stack_name}_${service}:"
|
||||
local i=1
|
||||
# Use regular arrays instead of associative arrays
|
||||
container_ids=()
|
||||
container_names=()
|
||||
|
||||
while read -r container_id; do
|
||||
if [ -n "$container_id" ]; then
|
||||
local container_name=$(docker inspect --format '{{.Name}}' "$container_id" | sed 's/\///')
|
||||
container_ids[$i]=$container_id
|
||||
container_names[$i]=$container_name
|
||||
echo "[$i] ${container_names[$i]} (${container_ids[$i]})"
|
||||
i=$((i+1))
|
||||
fi
|
||||
done <<< "$running_containers"
|
||||
|
||||
echo -e "\nPlease select a container number:"
|
||||
read -r selection
|
||||
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${container_ids[$selection]}" ]; then
|
||||
local selected_container=${container_ids[$selection]}
|
||||
clear
|
||||
echo "Showing logs for container: ${container_names[$selection]}"
|
||||
echo "Press Ctrl+C or 'q' to return to container selection"
|
||||
|
||||
# Start watching logs in the background
|
||||
docker container logs -f "$selected_container" &
|
||||
local log_pid=$!
|
||||
|
||||
while true; do
|
||||
read -r -n 1 input
|
||||
if [[ $input == "q" ]]; then
|
||||
kill $log_pid 2>/dev/null
|
||||
wait $log_pid 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
clear
|
||||
else
|
||||
echo "Error: Invalid selection"
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
# Single container case
|
||||
local container_name=$(docker inspect --format '{{.Name}}' "$running_containers" | sed 's/\///')
|
||||
echo "Showing logs for container: $container_name"
|
||||
echo "Press Ctrl+C or 'q' to exit logs"
|
||||
docker container logs -f "$running_containers" &
|
||||
local log_pid=$!
|
||||
|
||||
while true; do
|
||||
read -r -n 1 input
|
||||
if [[ $input == "q" ]]; then
|
||||
kill $log_pid 2>/dev/null
|
||||
wait $log_pid 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
function viewLogs(){
|
||||
|
||||
ARG_SERVICE_NAME=$2
|
||||
if [ -z "$ARG_SERVICE_NAME" ];
|
||||
then
|
||||
echo
|
||||
echo "Select a Service you want to view the logs for:"
|
||||
echo " 1) Web"
|
||||
echo " 2) Space"
|
||||
echo " 3) API"
|
||||
echo " 4) Worker"
|
||||
echo " 5) Beat-Worker"
|
||||
echo " 6) Migrator"
|
||||
echo " 7) Proxy"
|
||||
echo " 8) Redis"
|
||||
echo " 9) Postgres"
|
||||
echo " 10) Minio"
|
||||
echo " 11) RabbitMQ"
|
||||
echo " 0) Back to Main Menu"
|
||||
echo
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
|
||||
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
|
||||
echo "Invalid selection. Please enter a number between 0 and 11."
|
||||
read -p "Service: " DOCKER_SERVICE_NAME
|
||||
done
|
||||
|
||||
if [ -z "$DOCKER_SERVICE_NAME" ];
|
||||
then
|
||||
echo "INVALID SERVICE NAME SUPPLIED"
|
||||
else
|
||||
case $DOCKER_SERVICE_NAME in
|
||||
1) viewSpecificLogs "web";;
|
||||
2) viewSpecificLogs "space";;
|
||||
3) viewSpecificLogs "api";;
|
||||
4) viewSpecificLogs "worker";;
|
||||
5) viewSpecificLogs "beat-worker";;
|
||||
6) viewSpecificLogs "migrator";;
|
||||
7) viewSpecificLogs "proxy";;
|
||||
8) viewSpecificLogs "plane-redis";;
|
||||
9) viewSpecificLogs "plane-db";;
|
||||
10) viewSpecificLogs "plane-minio";;
|
||||
11) viewSpecificLogs "plane-mq";;
|
||||
0) askForAction;;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
fi
|
||||
elif [ -n "$ARG_SERVICE_NAME" ];
|
||||
then
|
||||
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
case $ARG_SERVICE_NAME in
|
||||
web) viewSpecificLogs "web";;
|
||||
space) viewSpecificLogs "space";;
|
||||
api) viewSpecificLogs "api";;
|
||||
worker) viewSpecificLogs "worker";;
|
||||
beat-worker) viewSpecificLogs "beat-worker";;
|
||||
migrator) viewSpecificLogs "migrator";;
|
||||
proxy) viewSpecificLogs "proxy";;
|
||||
redis) viewSpecificLogs "plane-redis";;
|
||||
postgres) viewSpecificLogs "plane-db";;
|
||||
minio) viewSpecificLogs "plane-minio";;
|
||||
rabbitmq) viewSpecificLogs "plane-mq";;
|
||||
*) echo "INVALID SERVICE NAME SUPPLIED";;
|
||||
esac
|
||||
else
|
||||
echo "INVALID SERVICE NAME SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
function askForAction() {
|
||||
# Rest of askForAction remains the same but use $stack_name instead of $STACK_NAME
|
||||
local DEFAULT_ACTION=$1
|
||||
|
||||
if [ -z "$DEFAULT_ACTION" ]; then
|
||||
echo
|
||||
echo "Select an Action you want to perform:"
|
||||
echo " 1) Deploy Stack"
|
||||
echo " 2) Remove Stack"
|
||||
echo " 3) View Stack Status"
|
||||
echo " 4) Redeploy Stack"
|
||||
echo " 5) Upgrade"
|
||||
echo " 6) View Logs"
|
||||
echo " 7) Exit"
|
||||
echo
|
||||
read -p "Action [3]: " ACTION
|
||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
||||
echo "$ACTION: invalid selection."
|
||||
read -p "Action [3]: " ACTION
|
||||
done
|
||||
|
||||
if [ -z "$ACTION" ]; then
|
||||
ACTION=3
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "deploy" ]; then
|
||||
deployStack
|
||||
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "remove" ]; then
|
||||
removeStack
|
||||
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "status" ]; then
|
||||
viewStatus
|
||||
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "redeploy" ]; then
|
||||
redeployStack
|
||||
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]; then
|
||||
upgrade
|
||||
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]; then
|
||||
viewLogs "$@"
|
||||
elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "exit" ]; then
|
||||
exit 0
|
||||
else
|
||||
echo "INVALID ACTION SUPPLIED"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialize stack name at script start
|
||||
|
||||
if [ -z "$stack_name" ]; then
|
||||
readStackName
|
||||
fi
|
||||
|
||||
# Sync environment variables
|
||||
if [ -f "$DOCKER_ENV_PATH" ]; then
|
||||
DOCKERHUB_USER=$(getEnvValue "DOCKERHUB_USER" "$DOCKER_ENV_PATH")
|
||||
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
|
||||
|
||||
if [ -z "$DOCKERHUB_USER" ]; then
|
||||
DOCKERHUB_USER=makeplane
|
||||
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "$APP_RELEASE" ]; then
|
||||
APP_RELEASE=stable
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Main execution
|
||||
print_header
|
||||
askForAction "$@"
|
||||
@@ -5,6 +5,9 @@ WEB_REPLICAS=1
|
||||
SPACE_REPLICAS=1
|
||||
ADMIN_REPLICAS=1
|
||||
API_REPLICAS=1
|
||||
WORKER_REPLICAS=1
|
||||
BEAT_WORKER_REPLICAS=1
|
||||
LIVE_REPLICAS=1
|
||||
|
||||
NGINX_PORT=80
|
||||
WEB_URL=http://${APP_DOMAIN}
|
||||
@@ -55,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
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.24.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
import { TDocumentTypes } from "@/core/types/common";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export type TAdditionalDocumentTypes = {};
|
||||
export type TAdditionalDocumentTypes = string;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,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,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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+6
-1
@@ -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
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/authentication.js"
|
||||
export * from "../../core/lib/authentication";
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/fetch-document.js"
|
||||
export * from "../../ce/lib/fetch-document";
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/update-document.js"
|
||||
export * from "../../ce/lib/update-document";
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
export * from "../../ce/types/common"
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"],
|
||||
});
|
||||
|
||||
+8
-5
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.2",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -22,12 +24,13 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.4.1"
|
||||
"turbo": "^2.4.2"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.1",
|
||||
"version": "0.25.2",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
"main": "./src/index.ts",
|
||||
"license": "AGPL-3.0"
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./filter";
|
||||
export * from "./layout";
|
||||
export * from "./modal";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TStaticViewTypes } from "@plane/types";
|
||||
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
|
||||
import { EUserWorkspaceRoles } from "./user";
|
||||
|
||||
export const ORGANIZATION_SIZE = [
|
||||
@@ -271,12 +271,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
||||
href: `/workspace-views/all-issues/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
"active-cycles": {
|
||||
key: "active_cycles",
|
||||
labelTranslationKey: "cycles",
|
||||
href: `/active-cycles/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
analytics: {
|
||||
key: "analytics",
|
||||
labelTranslationKey: "analytics",
|
||||
@@ -298,7 +292,6 @@ export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspa
|
||||
};
|
||||
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
|
||||
@@ -312,8 +305,8 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
||||
href: `/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
inbox: {
|
||||
key: "inbox",
|
||||
labelTranslationKey: "notification.label",
|
||||
href: `/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
@@ -328,6 +321,18 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.24.1",
|
||||
"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": [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import React from "react";
|
||||
// components
|
||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
@@ -35,7 +36,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
user,
|
||||
} = props;
|
||||
|
||||
const extensions = [];
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
@@ -10,7 +11,13 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -20,7 +27,7 @@ interface IDocumentReadOnlyEditor {
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
@@ -41,7 +48,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const extensions = [];
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user