Compare commits

..
414 changed files with 7631 additions and 32747 deletions
-6
View File
@@ -38,9 +38,3 @@ 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"
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
full_build_push:
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
runs-on: ubuntu-22.04
runs-on: ubuntu-20.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-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BUILD_TYPE: slim
+76 -3
View File
@@ -47,6 +47,12 @@ 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 }}
@@ -117,7 +123,46 @@ 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]
@@ -140,6 +185,7 @@ 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]
@@ -162,6 +208,7 @@ 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]
@@ -184,6 +231,7 @@ 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]
@@ -206,6 +254,7 @@ 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]
@@ -228,6 +277,7 @@ 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]
@@ -249,6 +299,31 @@ 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
@@ -262,6 +337,7 @@ 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 }}
@@ -272,8 +348,6 @@ 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
@@ -288,7 +362,6 @@ 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
+47 -4
View File
@@ -6,9 +6,49 @@ on:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
lint-apiserver:
get-changed-files:
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
@@ -23,7 +63,8 @@ jobs:
run: ruff check --fix apiserver
lint-admin:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -35,7 +76,8 @@ jobs:
- run: yarn lint --filter=admin
lint-space:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -47,7 +89,8 @@ jobs:
- run: yarn lint --filter=space
lint-web:
if: github.event.pull_request.draft == false
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
uses: actions/checkout@v4
full_build_push:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
needs: [branch_build_setup]
env:
BUILD_TYPE: full
+1 -1
View File
@@ -11,7 +11,7 @@ env:
jobs:
sync_changes:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
permissions:
pull-requests: write
contents: read
+4 -130
View File
@@ -62,143 +62,17 @@ 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).
+2 -4
View File
@@ -1,8 +1,6 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.25.2",
"license": "AGPL-3.0",
"version": "0.24.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -25,7 +23,7 @@
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
"axios": "^1.8.3",
"axios": "^1.7.9",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
+1 -7
View File
@@ -59,10 +59,4 @@ APP_BASE_URL=
# Hard delete files after days
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"
HARD_DELETE_AFTER_DAYS=60
+1 -4
View File
@@ -1,7 +1,4 @@
{
"name": "plane-api",
"version": "0.25.2",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
"version": "0.24.1"
}
+1 -5
View File
@@ -1,13 +1,9 @@
# python imports
import os
# Third party imports
from rest_framework.throttling import SimpleRateThrottle
class ApiKeyRateThrottle(SimpleRateThrottle):
scope = "api_key"
rate = os.environ.get("API_KEY_RATE_LIMIT", "60/minute")
rate = "60/minute"
def get_cache_key(self, request, view):
# Retrieve the API key from the request header
+2 -8
View File
@@ -80,7 +80,6 @@ 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)
@@ -159,13 +158,8 @@ class IssueSerializer(BaseSerializer):
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():
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
@@ -121,6 +121,8 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
from .draft import (
@@ -0,0 +1,21 @@
# 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"]
+11 -30
View File
@@ -36,7 +36,6 @@ from plane.db.models import (
State,
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
)
@@ -111,23 +110,14 @@ class IssueCreateSerializer(BaseSerializer):
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, attrs):
def validate(self, data):
if (
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)
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)
):
raise serializers.ValidationError("Start date cannot exceed target date")
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
return data
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
@@ -149,30 +139,22 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
assignee=user,
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
for user 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()
):
# Then assign it to default assignee
if default_assignee_id is not None:
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
@@ -222,14 +204,14 @@ class IssueCreateSerializer(BaseSerializer):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
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 assignee_id in assignees
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
@@ -540,7 +522,6 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"asset",
"attributes",
# "issue_id",
"created_by",
"updated_at",
"updated_by",
"asset_url",
+2
View File
@@ -2,6 +2,7 @@ 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
@@ -22,6 +23,7 @@ urlpatterns = [
*analytic_urls,
*asset_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
*external_urls,
*intake_urls,
+23
View File
@@ -0,0 +1,23 @@
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",
),
]
+2
View File
@@ -210,6 +210,8 @@ from .webhook.base import (
WebhookSecretRegenerateEndpoint,
)
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet
+812
View File
@@ -0,0 +1,812 @@
# 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)
+6 -8
View File
@@ -3,7 +3,7 @@ import os
from typing import List, Dict, Tuple
# Third party import
from openai import OpenAI
import litellm
import requests
from rest_framework import status
@@ -116,14 +116,12 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
if provider.lower() == "gemini":
model = f"gemini/{model}"
client = OpenAI(api_key=api_key)
chat_completion = client.chat.completions.create(
response = litellm.completion(
model=model,
messages=[
{"role": "user", "content": final_text}
]
messages=[{"role": "user", "content": final_text}],
api_key=api_key,
)
text = chat_completion.choices[0].message.content
text = response.choices[0].message.content.strip()
return text, None
except Exception as e:
log_exception(e)
@@ -177,7 +175,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"},
+3 -8
View File
@@ -174,19 +174,14 @@ class IntakeIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
intake = Intake.objects.filter(
intake_id = 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, project_id=project_id, **filters
intake_id=intake_id.id, project_id=project_id, **filters
)
.select_related("issue")
.prefetch_related("issue__labels")
@@ -387,7 +382,7 @@ class IntakeIssueViewSet(BaseViewSet):
}
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
issue, data=issue_data, partial=True
)
if issue_serializer.is_valid():
+9 -21
View File
@@ -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,9 +635,7 @@ class IssueViewSet(BaseViewSet):
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
@@ -1101,6 +1099,7 @@ 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(
@@ -1116,24 +1115,13 @@ 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
@@ -1235,8 +1223,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,
)
@@ -1252,7 +1240,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
"""
+1 -1
View File
@@ -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 if (module_issue.first() and module_issue.first().module) else None}
{"module_name": module_issue.first().module.name}
),
epoch=int(timezone.now().timestamp()),
notification=True,
@@ -34,22 +34,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
# If the favorite exists return
if request.data.get("entity_identifier"):
user_favorites = UserFavorite.objects.filter(
workspace=workspace,
user_id=request.user.id,
entity_type=request.data.get("entity_type"),
entity_identifier=request.data.get("entity_identifier"),
).first()
# If the favorite exists return
if user_favorites:
serializer = UserFavoriteSerializer(user_favorites)
return Response(serializer.data, status=status.HTTP_200_OK)
# else create a new favorite
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -100,20 +100,8 @@ class ResetPasswordEndpoint(View):
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
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)
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
@@ -11,6 +11,7 @@ from django.utils import timezone
# Module imports
from plane.utils.valid_uuid import is_valid_uuid
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
@@ -32,7 +33,6 @@ 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
@@ -853,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 current_issue:
if issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
@@ -1569,8 +1569,8 @@ def issue_activity(
try:
issue_activities = []
# check if project_id is valid
if not is_valid_uuid(str(project_id)):
# Validate UUIDs
if not is_valid_uuid(project_id):
return
project = Project.objects.get(pk=project_id)
@@ -1,41 +0,0 @@
# 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",
),
]
+1
View File
@@ -3,6 +3,7 @@ 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,
+92
View File
@@ -0,0 +1,92 @@
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",)
+1 -7
View File
@@ -32,12 +32,6 @@ 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",
@@ -45,7 +39,7 @@ class S3Storage(S3Boto3Storage):
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(
f"{endpoint_protocol}://{request.get_host()}"
f"{request.scheme}://{request.get_host()}"
if request
else self.aws_s3_endpoint_url
),
+2 -7
View File
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
from plane.db.models import IntakeIssue, Issue, State, IssueLink, FileAsset, DeployBoard
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -202,12 +202,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
issue_serializer = IssueCreateSerializer(
issue,
data=issue_data,
partial=True,
context={"project_id": project_deploy_board.project_id},
)
issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True)
if issue_serializer.is_valid():
current_instance = issue
+1 -1
View File
@@ -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 localized_datetime.date() == current_datetime_in_project_tz.date():
if utc_datetime.date() == current_datetime_in_utc.date():
return current_datetime_in_utc
return utc_datetime
+4 -3
View File
@@ -1,8 +1,9 @@
import uuid
def is_valid_uuid(uuid_str):
def is_valid_uuid(uuid_to_test, version=4):
try:
uuid.UUID(uuid_str, version=4)
return True
# check for validity of Uuid
uuid_obj = uuid.UUID(uuid_to_test, version=version)
except ValueError:
return False
return True
+4 -4
View File
@@ -1,10 +1,10 @@
# base requirements
# django
Django==4.2.20
Django==4.2.18
# 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
openai==1.63.2
litellm==1.51.0
# 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
+1 -1
View File
@@ -2,4 +2,4 @@
# debug toolbar
django-debug-toolbar==4.3.0
# formatter
ruff==0.9.7
ruff==0.4.2
+14 -136
View File
@@ -55,30 +55,18 @@ 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 Release
### Downloading Latest Stable 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
@@ -89,9 +77,8 @@ Lets get started by running the `./setup.sh` command.
This will prompt you with the below options.
#### Docker Compose
```bash
Select an Action you want to perform:
Select a Action you want to perform:
1) Install (x86_64)
2) Start
3) Stop
@@ -100,42 +87,17 @@ Select an 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 folder `plane-app` and will download 2 files inside that
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
- `docker-compose.yaml`
- `plane.env`
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.
Again the `options [1-8]` will be popped up and this time hit `8` to exit.
---
@@ -154,7 +116,7 @@ There are many other settings you can play with, but we suggest you configure `E
---
### Continue with setup - Start Server (Docker Compose)
### Continue with setup - Start Server
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices
@@ -185,11 +147,9 @@ You have successfully self hosted `Plane` instance. Access the application by go
---
### Stopping the Server / Remove Stack
### Stopping the Server
In case you want to make changes to `plane.env` variables, we suggest you to stop the services before doing that.
#### Docker Compose
In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices
@@ -211,34 +171,14 @@ If all goes well, you must see something like this
![Stop Services](images/stopped.png)
#### 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 / Redeploy Stack
### Restarting the Server
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.
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.
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)
@@ -257,32 +197,14 @@ If all goes well, you must see something like this
![Restart Services](images/restart.png)
#### 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)
@@ -309,41 +231,13 @@ 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.
#### Docker Compose
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs.
```bash
Select a Action you want to perform:
@@ -359,22 +253,7 @@ 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:
@@ -388,10 +267,9 @@ Select a Service you want to view the logs for:
8) Redis
9) Postgres
10) Minio
11) RabbitMQ
0) Back to Main Menu
Service: 3
Service:
```
Select any of the service to view the logs e.g. `3`. Expect something similar to this
@@ -445,7 +323,7 @@ Similarly, you can view the logs of other services.
---
### Backup Data (Docker Compose)
### Backup Data
There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives.
@@ -477,7 +355,7 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
---
### Restore Data (Docker Compose)
### Restore Data
When you want to restore the previously backed-up data, follow the instructions below.
+48 -57
View File
@@ -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,7 +28,8 @@ 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}
@@ -49,29 +50,33 @@ 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
@@ -79,39 +84,42 @@ 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
@@ -119,15 +127,14 @@ 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
@@ -136,15 +143,14 @@ 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
@@ -153,27 +159,23 @@ 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:
@@ -181,32 +183,24 @@ services:
plane-redis:
image: valkey/valkey:7.2.5-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
pull_policy: if_not_present
restart: unless-stopped
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
restart: always
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:
@@ -215,17 +209,13 @@ 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:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
- ${NGINX_PORT}:80
environment:
<<: *proxy-env
deploy:
replicas: 1
restart_policy:
condition: on-failure
depends_on:
- web
- api
@@ -234,6 +224,7 @@ services:
volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
+2 -5
View File
@@ -457,13 +457,12 @@ 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 <= 11 )); do
echo "Invalid selection. Please enter a number between 0 and 11."
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
echo "Invalid selection. Please enter a number between 1 and 11."
read -p "Service: " DOCKER_SERVICE_NAME
done
@@ -482,7 +481,6 @@ 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
@@ -501,7 +499,6 @@ 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
-612
View File
@@ -1,612 +0,0 @@
#!/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 "$@"
-8
View File
@@ -5,9 +5,6 @@ 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}
@@ -58,8 +55,3 @@ 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"
+19 -22
View File
@@ -1,22 +1,20 @@
{
"name": "live",
"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",
"version": "0.24.1",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"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",
"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",
@@ -25,16 +23,14 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^9.5.0",
"@sentry/profiling-node": "^9.5.0",
"@sentry/core": "^9.5.0",
"@sentry/node": "^9.0.1",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"axios": "^1.7.9",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -43,26 +39,27 @@
"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",
"zod": "^3.24.2",
"@plane/logger": "*"
"yjs": "^13.6.20"
},
"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",
"@types/reflect-metadata": "^0.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"tsup": "8.3.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.3.3"
}
}
+7 -18
View File
@@ -1,25 +1,14 @@
// types
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
import { TDocumentTypes } from "@/core/types/common.js";
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
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
};
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
+1 -1
View File
@@ -1,5 +1,5 @@
// types
import { TDocumentTypes } from "@/core/types/common";
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
cookie: string | undefined;
+1 -1
View File
@@ -1 +1 @@
export type TAdditionalDocumentTypes = string;
export type TAdditionalDocumentTypes = {};
-66
View File
@@ -1,66 +0,0 @@
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,
};
@@ -1,99 +0,0 @@
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,
};
}
}
@@ -1,39 +0,0 @@
import type { Request, Response } from "express";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// controller
import { BaseController } from "@/lib/base.controller";
// decorators
import { Post, CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
// error handling
import validate from "@/core/helpers/error-handling/error-validation";
export class DocumentController extends BaseController {
@Post("/convert-document")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
// Validate input parameters using our new validation utility
validate(description_html, "description_html").required().string();
validate(variant, "variant").required().string();
logger.info("Converting document", { variant });
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
}
}
-16
View File
@@ -1,16 +0,0 @@
import type { Request, Response } from "express";
import { Controller, Get, CatchErrors } from "@/lib/decorators";
import { BaseController } from "@/lib/base.controller";
@Controller("/health")
export class HealthController extends BaseController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || '1.0.0'
});
}
}
-4
View File
@@ -1,4 +0,0 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
+19
View File
@@ -0,0 +1,19 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
-58
View File
@@ -1,58 +0,0 @@
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();
+106 -91
View File
@@ -1,127 +1,142 @@
// hocuspocus extensions and core
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { setupRedisExtension } from "@/core/extensions/redis";
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";
// types
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";
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
export const getExtensions: () => Promise<Extension[]> = async () => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
logger.info(message);
manualLogger.info(message);
},
}),
new Database({
fetch: async ({
context,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const { documentType } = context;
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
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
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,
});
}
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" },
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
)();
return fetchedData;
});
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}: {
context: HocusPocusServerContext;
state: Buffer;
documentName: TDocumentTypes;
requestParameters: URLSearchParams;
}) => {
const { agentId } = context as HocusPocusServerContext;
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
catchAsync(
async () => {
if (!documentType) {
handleError(null, {
errorType: 'bad-request',
message: 'Document type is required',
component: 'database-extension',
operation: 'store',
extraContext: { pageId },
throw: true
// 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,
});
}
const documentHandler = getDocumentHandler(documentType, { agentId });
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
params,
});
},
{
params: { pageId, documentType },
extra: { operation: "store" },
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
)();
});
},
}),
];
// Set up Redis extension if available
const redisExtension = await setupRedisExtension();
if (redisExtension) {
extensions.push(redisExtension);
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)",
);
}
return extensions;
-380
View File
@@ -1,380 +0,0 @@
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";
}
};
@@ -1,32 +0,0 @@
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();
@@ -1,31 +0,0 @@
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 };
@@ -1,30 +0,0 @@
import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page";
import { DocumentHandler, DocumentFetchParams, DocumentStoreParams, HandlerDefinition } from "./types";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: async ({ pageId, params, context }: DocumentFetchParams) => {
const { cookie } = context;
return await fetchPageDescriptionBinary(params, pageId, cookie);
},
/**
* Store project page description
*/
store: async ({ pageId, state, params, context }: DocumentStoreParams) => {
const { cookie } = context;
await updatePageDescription(params, pageId, state, cookie);
}
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page" && !context.agentId,
handler: projectPageHandler,
priority: 10 // Standard priority
};
+21
View File
@@ -0,0 +1,21 @@
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 }),
},
});
};
@@ -1,277 +0,0 @@
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,
};
@@ -1,417 +0,0 @@
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,
})
);
});
}
@@ -1,56 +0,0 @@
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);
};
@@ -1,158 +0,0 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
-261
View File
@@ -1,261 +0,0 @@
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
});
}
};
+47 -74
View File
@@ -1,18 +1,17 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
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";
import { type HocusPocusServerContext } from "@/core/types/common.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
@@ -22,79 +21,53 @@ 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;
}) => {
// 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;
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();
}
}
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,
// 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) {
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!");
}
},
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 } }
);
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);
}
},
extensions,
debounce: 1000,
debounce: 10000,
});
};
+7 -27
View File
@@ -1,47 +1,27 @@
// services
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId, workspaceSlug } = props;
const { cookie, userId } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
console.log("caught?");
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
throw Error("Authentication failed: Token doesn't match the current user.");
}
return {
+72 -24
View File
@@ -1,64 +1,112 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service";
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
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);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
throw error;
}
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
) => {
if (!workspaceSlug || !projectId || !cookie) return;
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
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;
}
};
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;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
try {
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;
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
throw error;
}
};
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class PageService extends APIService {
constructor() {
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service";
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
export class UserService extends APIService {
constructor() {
-106
View File
@@ -1,106 +0,0 @@
// 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);
}
};
}
}
+1 -6
View File
@@ -1,15 +1,10 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId: string;
workspaceSlug: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
};
export type TConvertDocumentRequestBody = {
-62
View File
@@ -1,62 +0,0 @@
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 -2
View File
@@ -1,2 +1 @@
export * from "../../core/lib/authentication";
export * from "../../ce/lib/authentication.js"
+1 -2
View File
@@ -1,2 +1 @@
export * from "../../ce/lib/fetch-document";
export * from "../../ce/lib/fetch-document.js"
+1 -2
View File
@@ -1,2 +1 @@
export * from "../../ce/lib/update-document";
export * from "../../ce/lib/update-document.js"
+1 -1
View File
@@ -1 +1 @@
export * from "../../ce/types/common"
export * from "../../ce/types/common.js"
-52
View File
@@ -1,52 +0,0 @@
import * as dotenv from "dotenv";
import { z } from "zod";
// Load environment variables from .env file
dotenv.config();
// Define environment schema with validation
const envSchema = z.object({
// Server configuration
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.string().default("3000").transform(Number),
LIVE_BASE_PATH: z.string().default("/live"),
// CORS configuration
CORS_ALLOWED_ORIGINS: z
.string()
.default("*"),
// Sentry configuration
LIVE_SENTRY_DSN: z.string().optional(),
LIVE_SENTRY_ENVIRONMENT: z.string().default("development"),
LIVE_SENTRY_TRACES_SAMPLE_RATE: z.string().default("0.5").transform(Number),
LIVE_SENTRY_RELEASE: z.string().optional(),
LIVE_SENTRY_RELEASE_VERSION: z.string().optional(),
// Compression options
COMPRESSION_LEVEL: z.string().default("6").transform(Number),
COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number),
// Hocuspocus server configuration
HOCUSPOCUS_URL: z.string().optional(),
HOCUSPOCUS_USERNAME: z.string().optional(),
HOCUSPOCUS_PASSWORD: z.string().optional(),
// Graceful shutdown timeout
SHUTDOWN_TIMEOUT: z.string().default("10000").transform(Number),
});
// Validate the environment variables
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4));
process.exit(1);
}
return result.data;
}
// Export the validated environment
export const env = validateEnv();
-92
View File
@@ -1,92 +0,0 @@
import { Router, Request } from "express";
import { WebSocket } from "ws";
import "reflect-metadata";
import Errors from "@/core/helpers/error-handling/error-factory";
import { ErrorCategory ,asyncHandler} from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
export abstract class BaseController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Register all routes for this controller
*/
public registerRoutes(router: Router): void {
const baseRoute = this.getBaseRoute();
const proto = Object.getPrototypeOf(this);
const methods = Object.getOwnPropertyNames(proto).filter(
(item) => item !== "constructor" && typeof (this as any)[item] === "function"
);
methods.forEach((methodName) => {
const route = Reflect.getMetadata("route", proto, methodName) || "";
const method = Reflect.getMetadata("method", proto, methodName) as string;
const middlewares = Reflect.getMetadata("middlewares", proto, methodName) || [];
if (route && method) {
const fullRoute = baseRoute + route;
const handler = (this as any)[methodName].bind(this);
if (method === "ws") {
// Handle WebSocket routes with error handling for graceful failures
(router as any).ws(fullRoute, (ws: WebSocket, req: Request) => {
try {
handler(ws, req);
} catch (error: unknown) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
controller: this.constructor.name,
method: methodName,
route: fullRoute,
requestId: req.id || "unknown",
},
});
logger.error(`WebSocket error in ${this.constructor.name}.${methodName}`, {
error: appError,
});
// Send error message to client before closing
try {
ws.send(
JSON.stringify({
type: "error",
message:
appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
} catch (sendError) {
// Ignore send errors at this point
}
// Close the connection with an appropriate code
ws.close(1011, appError.message);
}
});
} else {
(router as any)[method](fullRoute, ...middlewares, asyncHandler(handler));
}
}
});
// Mount this controller's router on the main router
router.use(baseRoute, this.router);
}
}
export abstract class BaseWebSocketController extends BaseController {
abstract handleConnection(ws: WebSocket, req: Request): void;
}
-64
View File
@@ -1,64 +0,0 @@
import { Router, Request } from "express";
import { WebSocket } from "ws";
import { BaseController, BaseWebSocketController } from "./base.controller";
/**
* Controller factory function interface for creating controllers with dependencies
*/
export type ControllerFactory<T extends BaseController = BaseController> = (dependencies: any) => T;
/**
* Interface for controller registration with optional dependencies
*/
export interface ControllerRegistration {
Controller: new (...args: any[]) => BaseController;
dependencies?: string[];
}
/**
* Interface for WebSocket controller registration with optional dependencies
*/
export interface WebSocketControllerRegistration {
Controller: new (...args: any[]) => BaseWebSocketController;
dependencies?: string[];
}
/**
* Controller registry interface for organizing controllers
*/
export interface IControllerRegistry {
controllers: ControllerRegistration[];
webSocketControllers: WebSocketControllerRegistration[];
}
/**
* Service container interface for dependency management
*/
export interface IServiceContainer {
get(serviceName: string): any;
register(serviceName: string, service: any): void;
}
/**
* Base controller interface that all controllers should implement
*/
export interface IController {
/**
* Register routes for RESTful endpoints
* @param router Express router to register routes on
*/
registerRoutes?(router: Router): void;
}
/**
* WebSocket controller interface for websocket handlers
*/
export interface IWebSocketController extends IController {
/**
* Handle WebSocket connections
* @param ws WebSocket connection
* @param req Express request object
*/
handleConnection(ws: WebSocket, req: Request): void;
}
-50
View File
@@ -1,50 +0,0 @@
import { Router } from "express";
import { IControllerRegistry, IServiceContainer, ControllerRegistration, WebSocketControllerRegistration } from "./controller.interface";
import "reflect-metadata";
// Define valid HTTP methods
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'all' | 'use' | 'options' | 'head' | 'ws';
/**
* Register all controllers from a registry with dependency injection
* @param router Express router to register routes on
* @param registry Controller registry containing all controllers
* @param container Service container for dependency injection
*/
export function registerControllers(
router: Router,
registry: IControllerRegistry,
container: IServiceContainer
): void {
// Register REST controllers
registry.controllers.forEach((registration) => {
const { Controller, dependencies = [] } = registration;
const resolvedDependencies = dependencies.map(dep => container.get(dep));
const instance = new Controller(...resolvedDependencies);
instance.registerRoutes(router);
});
// Register WebSocket controllers
registry.webSocketControllers.forEach((registration) => {
const { Controller, dependencies = [] } = registration;
const resolvedDependencies = dependencies.map(dep => container.get(dep));
const instance = new Controller(...resolvedDependencies);
instance.registerRoutes(router);
});
}
/**
* Create a controller registry with the given controllers
* @param controllers Array of REST controller registrations
* @param webSocketControllers Array of WebSocket controller registrations
* @returns Controller registry
*/
export function createControllerRegistry(
controllers: ControllerRegistration[],
webSocketControllers: WebSocketControllerRegistration[] = []
): IControllerRegistry {
return {
controllers,
webSocketControllers,
};
}
-78
View File
@@ -1,78 +0,0 @@
import "reflect-metadata";
import type { RequestHandler } from "express";
import { asyncHandler } from "@/core/helpers/error-handling/error-handler";
export const Controller = (baseRoute = ""): ClassDecorator => {
return function (target: object) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
};
export const Get = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "get", target, propertyKey);
};
};
export const Post = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "post", target, propertyKey);
};
};
export const Put = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "put", target, propertyKey);
};
};
export const Patch = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "patch", target, propertyKey);
};
};
export const Delete = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "delete", target, propertyKey);
};
};
export const Middleware = (middleware: RequestHandler): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
};
export const WebSocket = (route: string): MethodDecorator => {
return function (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) {
Reflect.defineMetadata("route", route, target, propertyKey);
Reflect.defineMetadata("method", "ws", target, propertyKey);
};
};
/**
* Decorator to wrap controller methods with error handling
* This automatically catches and processes all errors using our error handling system
*/
export const CatchErrors = (): MethodDecorator => {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// Only apply to methods that are not WebSocket handlers
const isWebSocketHandler = Reflect.getMetadata("method", target, propertyKey) === "ws";
if (typeof originalMethod === 'function' && !isWebSocketHandler) {
descriptor.value = asyncHandler(originalMethod);
}
return descriptor;
};
};
-30
View File
@@ -1,30 +0,0 @@
import { IServiceContainer } from "./controller.interface";
/**
* Simple service container implementation for dependency injection
*/
export class ServiceContainer implements IServiceContainer {
private services: Map<string, any> = new Map();
/**
* Register a service in the container
* @param serviceName Name of the service
* @param service The service instance
*/
register(serviceName: string, service: any): void {
this.services.set(serviceName, service);
}
/**
* Get a service from the container
* @param serviceName Name of the service
* @returns The service instance
* @throws Error if service not found
*/
get(serviceName: string): any {
if (!this.services.has(serviceName)) {
throw new Error(`Service ${serviceName} not found in container`);
}
return this.services.get(serviceName);
}
}
-27
View File
@@ -1,27 +0,0 @@
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;
+121 -155
View File
@@ -1,174 +1,140 @@
import express from "express";
import type { Application, Request, Router } from "express";
import * as Sentry from "@sentry/node";
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
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";
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
import { initializeSentry } from "./sentry-config";
const app = express();
expressWs(app);
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { controllerRegistry } from "@/core/controller-registry";
import { ShutdownManager } from "@/core/shutdown-manager";
app.set("port", process.env.PORT || 3000);
// Service and controller related
import { IControllerRegistry, IServiceContainer } from "./lib/controller.interface";
import { registerControllers } from "./lib/controller.utils";
import { ServiceContainer } from "./lib/service-container";
// Security middleware
app.use(helmet());
// Logging
import { logger } from "@plane/logger";
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
})
);
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Logging middleware
app.use(logger);
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
/**
* 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;
// cors middleware
app.use(cors());
/**
* 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 router = express.Router();
// Initialize express-ws after Express setup
expressWs(this.app as any);
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
// Configure server
this.setupSentry();
configureServerMiddleware(this.app);
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();
}
});
/**
* 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,
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",
});
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);
}
/**
* Initialize core services
*/
private async initializeServices() {
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Register services in the container
this.serviceContainer.register("hocuspocus", this.hocusPocusServer);
}
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 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,
});
}
}
}
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
-26
View File
@@ -1,26 +0,0 @@
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();
+10 -22
View File
@@ -1,38 +1,26 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"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,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
}
+9 -15
View File
@@ -1,17 +1,11 @@
import { defineConfig } from "tsup";
import { defineConfig, Options } from "tsup";
export default defineConfig({
entry: ["src/start.ts"],
format: ["esm", "cjs"],
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
minify: false,
target: "node18",
outDir: "dist",
env: {
NODE_ENV: process.env.NODE_ENV || "development",
},
watch: ["src/**/*.{ts,tsx}"],
});
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
+5 -8
View File
@@ -1,8 +1,6 @@
{
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.25.2",
"version": "0.24.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -24,13 +22,12 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.4.2"
"turbo": "^2.4.1"
},
"resolutions": {
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10"
"esbuild": "0.25.0"
},
"packageManager": "yarn@1.22.22"
"packageManager": "yarn@1.22.22",
"name": "plane"
}
+2 -3
View File
@@ -1,7 +1,6 @@
{
"name": "@plane/constants",
"version": "0.25.2",
"version": "0.24.1",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
"main": "./src/index.ts"
}
+24 -160
View File
@@ -1,4 +1,8 @@
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IIssueDisplayProperties,
} from "@plane/types";
export const ALL_ISSUES = "All Issues";
@@ -145,24 +149,25 @@ 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;
@@ -210,144 +215,3 @@ export const ISSUE_DISPLAY_PROPERTIES: {
{ key: "modules", titleTranslationKey: "common.module" },
{ key: "cycle", titleTranslationKey: "common.cycle" },
];
export const SPREADSHEET_PROPERTY_LIST: (keyof IIssueDisplayProperties)[] = [
"state",
"priority",
"assignee",
"labels",
"modules",
"cycle",
"start_date",
"due_date",
"estimate",
"created_on",
"updated_on",
"link",
"attachment_count",
"sub_issue_count",
];
export const SPREADSHEET_PROPERTY_DETAILS: {
[key in keyof IIssueDisplayProperties]: {
i18n_title: string;
ascendingOrderKey: TIssueOrderByOptions;
ascendingOrderTitle: string;
descendingOrderKey: TIssueOrderByOptions;
descendingOrderTitle: string;
icon: string;
};
} = {
assignee: {
i18n_title: "common.assignees",
ascendingOrderKey: "assignees__first_name",
ascendingOrderTitle: "A",
descendingOrderKey: "-assignees__first_name",
descendingOrderTitle: "Z",
icon: "Users",
},
created_on: {
i18n_title: "common.sort.created_on",
ascendingOrderKey: "-created_at",
ascendingOrderTitle: "New",
descendingOrderKey: "created_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
due_date: {
i18n_title: "common.order_by.due_date",
ascendingOrderKey: "-target_date",
ascendingOrderTitle: "New",
descendingOrderKey: "target_date",
descendingOrderTitle: "Old",
icon: "CalendarCheck2",
},
estimate: {
i18n_title: "common.estimate",
ascendingOrderKey: "estimate_point__key",
ascendingOrderTitle: "Low",
descendingOrderKey: "-estimate_point__key",
descendingOrderTitle: "High",
icon: "Triangle",
},
labels: {
i18n_title: "common.labels",
ascendingOrderKey: "labels__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-labels__name",
descendingOrderTitle: "Z",
icon: "Tag",
},
modules: {
i18n_title: "common.modules",
ascendingOrderKey: "issue_module__module__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_module__module__name",
descendingOrderTitle: "Z",
icon: "DiceIcon",
},
cycle: {
i18n_title: "common.cycle",
ascendingOrderKey: "issue_cycle__cycle__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-issue_cycle__cycle__name",
descendingOrderTitle: "Z",
icon: "ContrastIcon",
},
priority: {
i18n_title: "common.priority",
ascendingOrderKey: "priority",
ascendingOrderTitle: "None",
descendingOrderKey: "-priority",
descendingOrderTitle: "Urgent",
icon: "Signal",
},
start_date: {
i18n_title: "common.order_by.start_date",
ascendingOrderKey: "-start_date",
ascendingOrderTitle: "New",
descendingOrderKey: "start_date",
descendingOrderTitle: "Old",
icon: "CalendarClock",
},
state: {
i18n_title: "common.state",
ascendingOrderKey: "state__name",
ascendingOrderTitle: "A",
descendingOrderKey: "-state__name",
descendingOrderTitle: "Z",
icon: "DoubleCircleIcon",
},
updated_on: {
i18n_title: "common.sort.updated_on",
ascendingOrderKey: "-updated_at",
ascendingOrderTitle: "New",
descendingOrderKey: "updated_at",
descendingOrderTitle: "Old",
icon: "CalendarDays",
},
link: {
i18n_title: "common.link",
ascendingOrderKey: "-link_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "link_count",
descendingOrderTitle: "Least",
icon: "Link2",
},
attachment_count: {
i18n_title: "common.attachment",
ascendingOrderKey: "-attachment_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "attachment_count",
descendingOrderTitle: "Least",
icon: "Paperclip",
},
sub_issue_count: {
i18n_title: "issue.display.properties.sub_issue",
ascendingOrderKey: "-sub_issues_count",
ascendingOrderTitle: "Most",
descendingOrderKey: "sub_issues_count",
descendingOrderTitle: "Least",
icon: "LayersIcon",
},
};
-1
View File
@@ -1,4 +1,3 @@
export * from "./common";
export * from "./filter";
export * from "./layout";
export * from "./modal";
-19
View File
@@ -1,19 +0,0 @@
// 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,
};
+9 -6
View File
@@ -1,5 +1,8 @@
// icons
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import {
TProjectAppliedDisplayFilterKeys,
TProjectOrderByOptions,
} from "@plane/types";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -52,11 +55,11 @@ export const GROUP_CHOICES = {
};
export const PROJECT_AUTOMATION_MONTHS = [
{ 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 },
{ 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 },
];
export const PROJECT_UNSPLASH_COVERS = [
+11 -16
View File
@@ -1,4 +1,4 @@
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
import { TStaticViewTypes } from "@plane/types";
import { EUserWorkspaceRoles } from "./user";
export const ORGANIZATION_SIZE = [
@@ -271,6 +271,12 @@ 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",
@@ -292,6 +298,7 @@ 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"],
@@ -305,8 +312,8 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
inbox: {
key: "inbox",
notifications: {
key: "notifications",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
@@ -321,18 +328,6 @@ 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["inbox"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];
export const WORKSPACE_DEFAULT_SEARCH_RESULT: IWorkspaceSearchResults = {
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
};
+2 -3
View File
@@ -1,8 +1,7 @@
{
"name": "@plane/editor",
"version": "0.25.2",
"version": "0.24.1",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -81,7 +80,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "8.3.0",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
"keywords": [
@@ -1,4 +1,3 @@
import { Extensions } from "@tiptap/core";
import React from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
@@ -36,7 +35,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
user,
} = props;
const extensions: Extensions = [];
const extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
@@ -1,4 +1,3 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
// components
import { PageRenderer } from "@/components/editors";
@@ -11,13 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
@@ -27,7 +20,7 @@ interface IDocumentReadOnlyEditor {
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: TReadOnlyFileHandler;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
@@ -48,7 +41,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
initialValue,
mentionHandler,
} = props;
const extensions: Extensions = [];
const extensions = [];
if (embedHandler?.issue) {
extensions.push(
IssueWidget({
@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
// plane utils
import { cn } from "@plane/utils";
// constants
@@ -71,7 +71,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
"editor-container cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
@@ -23,7 +23,6 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -34,7 +34,6 @@ export const BlockMenu = (props: BlockMenuProps) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - Tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -6,15 +6,15 @@ import { cn } from "@plane/utils";
import { TextAlignItem } from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
editorState: EditorStateType;
onClose: () => void;
};
export const TextAlignmentSelector: React.FC<Props> = (props) => {
const { editor, editorState } = props;
const { editor, onClose } = props;
const menuItem = TextAlignItem(editor);
const textAlignmentOptions: {
@@ -32,7 +32,10 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "left",
}),
isActive: () => editorState.left,
isActive: () =>
menuItem.isActive({
alignment: "left",
}),
},
{
itemKey: "text-align",
@@ -42,7 +45,10 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "center",
}),
isActive: () => editorState.center,
isActive: () =>
menuItem.isActive({
alignment: "center",
}),
},
{
itemKey: "text-align",
@@ -52,7 +58,10 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
menuItem.command({
alignment: "right",
}),
isActive: () => editorState.right,
isActive: () =>
menuItem.isActive({
alignment: "right",
}),
},
];
@@ -65,6 +74,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
onClick={(e) => {
e.stopPropagation();
item.command();
onClose();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
@@ -1,26 +1,24 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
editorState: EditorStateType;
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen, editorState } = props;
const { editor, isOpen, setIsOpen } = props;
const activeTextColor = editorState.color;
const activeBackgroundColor = editorState.backgroundColor;
const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key }));
const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key }));
return (
<div className="relative h-full">

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