Compare commits

..
Author SHA1 Message Date
Palanikannan M f0da90f184 fix: title update starting 2025-04-03 19:00:47 +05:30
Palanikannan M 84d403fb40 fix: 3s debounce delay post save 2025-04-03 18:56:01 +05:30
Palanikannan M 8becfffed2 fix: title sync done 2025-04-03 18:46:44 +05:30
Palanikannan M bdb14d519d Merge branch 'preview' into feat/title-sync 2025-04-03 18:05:25 +05:30
Palanikannan M d55b92ab1c Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 16:42:15 +05:30
Palanikannan M f168bd3beb chore: remove services 2025-04-02 16:42:08 +05:30
Palanikannan M a74fdf235b fix: common initializer 2025-04-02 15:44:52 +05:30
Palanikannan M 9f441ef136 Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 15:31:22 +05:30
Palanikannan M ad9888ce45 fix: registering handlers made simplified 2025-04-02 15:31:05 +05:30
Palanikannan M 16bd86dc0c Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-04-02 14:22:16 +05:30
Palanikannan M 4eef115fcd Merge branch 'preview' into fix/live-server-restructuring 2025-04-02 13:19:29 +05:30
Palanikannan M cabd5f4275 fix: moving around pages 2025-04-02 13:10:47 +05:30
Aaryan Khandelwal c5c3344ba0 fix: title position 2025-03-27 15:08:08 +05:30
Palanikannan M 7393f2ed7f merge live-server-restructuring 2025-03-26 21:34:39 +05:30
Palanikannan M bc4398e344 Merge branch 'preview' into feat/title-sync 2025-03-26 21:30:09 +05:30
Palanikannan M 7cbdcd4c94 fix: added zod validation 2025-03-26 19:58:32 +05:30
Palanikannan M cef60b55e4 fix: error factory types and document controller error handling 2025-03-26 15:52:36 +05:30
Palanikannan M 07bf68c8ca fix: exit with error 1 2025-03-26 14:55:41 +05:30
Palanikannan M 3d07d0f678 fix: add missing packages 2025-03-26 14:52:11 +05:30
Palanikannan M 6c2fb4b287 merge decorators 2025-03-26 14:40:55 +05:30
Palanikannan M c1b8feaf6f chore: Merge preview 2025-03-26 14:01:50 +05:30
Palanikannan M 8a9cdc6133 fix: refactor decorators 2025-03-26 13:59:28 +05:30
Palanikannan M 6db75fe29c fix: added package dependency 2025-03-26 00:05:19 +05:30
Palanikannan M ea7ebe66b1 feat: express decorators for rest apis and websocket 2025-03-25 20:30:47 +05:30
Palanikannan M 93066ef5d5 Merge branch 'fix/live-server-restructuring' into fix/live-server-restructuring 2025-03-25 19:46:58 +05:30
Palanikannan M 2db9d35678 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-25 19:42:44 +05:30
Palanikannan M 37cd01e306 feat: express decorators for rest apis and websocket 2025-03-25 17:51:53 +05:30
Palanikannan M d3defc9785 fix: redis process improved a lot 2025-03-24 22:03:59 +05:30
Palanikannan M 6d087168e2 fix: syncing title using mobx 2025-03-22 03:47:08 +05:30
Palanikannan M 8ba72e7c3d Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-03-22 03:17:39 +05:30
Palanikannan M 3a0891e0ee fix: extensions index ts cleaned up 2025-03-22 03:06:12 +05:30
Palanikannan M b035e63d38 fix: title updation with new structure 2025-03-22 02:58:13 +05:30
Palanikannan M 251149bbfa Merge branch 'fix/live-server-restructuring' into feat/title-sync 2025-03-22 02:23:59 +05:30
Palanikannan M 53efad3399 chore: restructuring project-page methods, transformers and handlers 2025-03-22 02:23:37 +05:30
Palanikannan M 304ef1a80c Merge branch 'preview' into fix/live-server-restructuring 2025-03-22 01:51:37 +05:30
Palanikannan M 16d41a3841 fix: not shutting down the app for any reason 2025-03-22 01:51:28 +05:30
Palanikannan M c2b53cc38e fix: title saving logic with debounce and abortion handled neatly 2025-03-22 00:22:00 +05:30
NarayanBavisetti 629d1943f3 chore: added title of page in binary request 2025-03-21 15:12:54 +05:30
Palanikannan M a9f4427b21 fix: seperated decorators into it's own package 2025-03-21 03:04:19 +05:30
Palanikannan M 22905ca662 fix: navigation between title and main editor made seamless 2025-03-21 02:22:01 +05:30
Palanikannan M d9df474b66 fix: added title sync capabilities along with migration 2025-03-20 20:21:37 +05:30
Palanikannan M e4f31aea08 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-19 16:01:26 +05:30
Palanikannan M c2a3e47d3d fix: stop event prop on error 2025-03-19 16:01:09 +05:30
Palanikannan M cef4110eb0 fix: handlers 2025-03-19 14:01:19 +05:30
Palanikannan M 3672ee4ef1 fix: errors and imports 2025-03-18 19:25:31 +05:30
Palanikannan M c56097b8c0 fix: better error handling for redis client 2025-03-18 18:56:50 +05:30
Palanikannan M 0d57e0ab32 fix: file structure for error handling 2025-03-18 17:32:46 +05:30
Palanikannan M df35ccecc9 fix: better error handling 2025-03-18 16:59:04 +05:30
Palanikannan M 38d8d3ea9b fix: dividing server code 2025-03-18 01:36:14 +05:30
Palanikannan M 3710b182d3 fix: tsup hot reloading 2025-03-18 00:16:30 +05:30
Palanikannan M 6897575a62 fix: logger and added working global error handling with sentry 2025-03-17 23:24:03 +05:30
Palanikannan M 388151b70b Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-03-17 15:52:49 +05:30
Palanikannan M 3d61604569 Merge branch 'preview' into devin/1734544044-refactor-live-server 2025-02-08 20:32:50 +05:30
Palanikannan M 1b29f65664 fix: removed .js imports 2024-12-23 18:16:53 +05:30
Palanikannan M a229508611 Merge branch 'preview' into devin/1734544044-refactor-live-server 2024-12-23 17:23:59 +05:30
Devin AIandsriram@plane.so <sriram@plane.so> d5bd4ef63a chore: switch from esbuild to tsup for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:49:27 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 146332fff3 chore: replace babel with esbuild for build configuration
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-19 07:42:19 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 5802858772 fix: resolve typescript errors in server and decorators
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:05:01 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> b39ce9c18a fix: add eslint config and fix websocket router interface
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:03:29 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> a7ab5ae680 fix: resolve lint errors in decorators and collaboration controller
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:02:48 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> f2a08853e2 feat: add start.ts entry point for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 18:01:12 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 23eeb45713 feat: implement collaboration controller with websocket support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:54:15 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> 6c83a0df09 feat: implement health check controller with decorator support
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:52:40 +00:00
Devin AIandsriram@plane.so <sriram@plane.so> dbee7488e1 feat: add decorator system and controller registration for live server
Co-Authored-By: sriram@plane.so <sriram@plane.so>
2024-12-18 17:50:52 +00:00
373 changed files with 10416 additions and 12549 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.26",
"next": "^14.2.25",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -9
View File
@@ -39,15 +39,7 @@ class CycleSerializer(BaseSerializer):
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id
if self.instance and hasattr(self.instance, "project_id")
else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
project_id = self.initial_data.get("project_id") or self.instance.project_id
is_start_date_end_date_equal = (
True
if str(data.get("start_date")) == str(data.get("end_date"))
@@ -16,6 +16,7 @@ class ProjectSerializer(BaseSerializer):
member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True)
cover_image_url = serializers.CharField(read_only=True)
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
class Meta:
model = Project
+10
View File
@@ -4,6 +4,16 @@ from plane.api.views import IntakeIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
IntakeIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:issue_id>/",
IntakeIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-issues/",
IntakeIssueAPIEndpoint.as_view(),
+14 -34
View File
@@ -141,10 +141,8 @@ class CycleAPIEndpoint(BaseAPIView):
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset,
fields=self.fields,
expand=self.expand,
context={"project": project},
queryset, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -156,11 +154,8 @@ class CycleAPIEndpoint(BaseAPIView):
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
queryset, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
@@ -171,11 +166,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -186,11 +178,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -201,11 +190,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -218,22 +204,16 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
return self.paginate(
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles,
many=True,
fields=self.fields,
expand=self.expand,
context={"project": project},
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
-12
View File
@@ -57,7 +57,6 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
class WorkspaceIssueAPIEndpoint(BaseAPIView):
@@ -323,17 +322,6 @@ class IssueAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Send the model activity
model_activity.delay(
model_name="issue",
model_id=str(serializer.data["id"]),
requested_data=request.data,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+7 -6
View File
@@ -32,7 +32,6 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
from .base import BaseAPIView
from plane.utils.host import base_host
class ProjectAPIEndpoint(BaseAPIView):
"""Project Endpoints to create, update, list, retrieve and delete endpoint"""
@@ -239,7 +238,7 @@ class ProjectAPIEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist:
return Response(
@@ -248,7 +247,7 @@ class ProjectAPIEndpoint(BaseAPIView):
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, pk):
@@ -259,7 +258,9 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get("intake_view", project.intake_view)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", project.intake_view)
)
if project.archived_at:
return Response(
@@ -306,7 +307,7 @@ class ProjectAPIEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
@@ -315,7 +316,7 @@ class ProjectAPIEndpoint(BaseAPIView):
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
def delete(self, request, slug, pk):
+2 -24
View File
@@ -352,19 +352,8 @@ class IssueRelationSerializer(BaseSerializer):
"state_id",
"priority",
"assignee_ids",
"created_by",
"created_at",
"updated_at",
"updated_by",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = ["workspace", "project"]
class RelatedIssueSerializer(BaseSerializer):
@@ -394,19 +383,8 @@ class RelatedIssueSerializer(BaseSerializer):
"state_id",
"priority",
"assignee_ids",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = ["workspace", "project"]
class IssueAssigneeSerializer(BaseSerializer):
+1 -15
View File
@@ -1,11 +1,7 @@
from django.urls import path
from plane.app.views import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
from plane.app.views import IntakeViewSet, IntakeIssueViewSet
urlpatterns = [
@@ -57,14 +53,4 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
IntakeWorkItemDescriptionVersionEndpoint.as_view(),
name="intake-work-item-versions",
),
]
+9 -9
View File
@@ -25,7 +25,7 @@ from plane.app.views import (
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
WorkItemDescriptionVersionEndpoint,
IssueDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
@@ -263,22 +263,22 @@ urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="issue-versions",
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="issue-versions",
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:work_item_id>/description-versions/<uuid:pk>/",
WorkItemDescriptionVersionEndpoint.as_view(),
name="work-item-versions",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
+2 -6
View File
@@ -144,7 +144,7 @@ from .issue.sub_issue import SubIssuesEndpoint
from .issue.subscriber import IssueSubscriberViewSet
from .issue.version import IssueVersionEndpoint, WorkItemDescriptionVersionEndpoint
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
from .module.base import (
ModuleViewSet,
@@ -184,11 +184,7 @@ from .estimate.base import (
EstimatePointEndpoint,
)
from .intake.base import (
IntakeViewSet,
IntakeIssueViewSet,
IntakeWorkItemDescriptionVersionEndpoint,
)
from .intake.base import IntakeViewSet, IntakeIssueViewSet
from .analytic.base import (
AnalyticsEndpoint,
+10 -107
View File
@@ -27,22 +27,16 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
IssueDescriptionVersion,
)
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueSerializer,
IntakeSerializer,
IntakeIssueSerializer,
IntakeIssueDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activities_task import issue_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.app.views.base import BaseAPIView
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
@@ -94,7 +88,7 @@ class IntakeIssueViewSet(BaseViewSet):
serializer_class = IntakeIssueSerializer
model = IntakeIssue
filterset_fields = ["status"]
filterset_fields = ["statulls"]
def get_queryset(self):
return (
@@ -225,7 +219,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
@@ -293,13 +287,6 @@ class IntakeIssueViewSet(BaseViewSet):
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(serializer.data["id"]),
user_id=request.user.id,
is_creating=True,
)
intake_issue = (
IntakeIssue.objects.select_related("issue")
.prefetch_related("issue__labels", "issue__assignees")
@@ -399,16 +386,13 @@ class IntakeIssueViewSet(BaseViewSet):
),
"description": issue_data.get("description", issue.description),
}
current_instance = json.dumps(
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
)
issue_serializer = IssueCreateSerializer(
issue, data=issue_data, partial=True, context={"project_id": project_id}
)
if issue_serializer.is_valid():
current_instance = issue
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
@@ -418,18 +402,15 @@ class IntakeIssueViewSet(BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=current_instance,
current_instance=json.dumps(
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
issue_serializer.save()
else:
return Response(
@@ -569,7 +550,7 @@ class IntakeIssueViewSet(BaseViewSet):
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
@@ -577,7 +558,7 @@ class IntakeIssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
status=status.HTTP_400_BAD_REQUEST,
)
issue = IntakeIssueDetailSerializer(intake_issue).data
return Response(issue, status=status.HTTP_200_OK)
@@ -604,81 +585,3 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=work_item_id,
pk=pk,
)
serializer = IssueDescriptionVersionDetailSerializer(
issue_description_version
)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
)
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
+3 -12
View File
@@ -63,7 +63,6 @@ from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
from plane.utils.host import base_host
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
@@ -566,7 +565,7 @@ class IssueViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
@@ -633,7 +632,7 @@ class IssueViewSet(BaseViewSet):
)
current_instance = json.dumps(
IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
@@ -1035,17 +1034,9 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
"""
Validate that start date is before target date.
"""
from datetime import datetime
start = new_start or current_start
target = new_target or current_target
# Convert string dates to datetime objects if they're strings
if isinstance(start, str):
start = datetime.strptime(start, "%Y-%m-%d").date()
if isinstance(target, str):
target = datetime.strptime(target, "%Y-%m-%d").date()
if start and target and start > target:
return False
return True
@@ -1287,7 +1278,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
+6 -36
View File
@@ -3,13 +3,7 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import (
IssueVersion,
IssueDescriptionVersion,
Project,
ProjectMember,
Issue,
)
from plane.db.models import IssueVersion, IssueDescriptionVersion
from ..base import BaseAPIView
from plane.app.serializers import (
IssueVersionDetailSerializer,
@@ -72,7 +66,7 @@ class IssueVersionEndpoint(BaseAPIView):
return Response(paginated_data, status=status.HTTP_200_OK)
class WorkItemDescriptionVersionEndpoint(BaseAPIView):
class IssueDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -84,34 +78,10 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, work_item_id, pk=None):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=work_item_id
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=ROLE.GUEST.value,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=work_item_id,
pk=pk,
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
serializer = IssueDescriptionVersionDetailSerializer(
@@ -135,8 +105,8 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=work_item_id
).order_by("-created_at")
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
+1
View File
@@ -550,6 +550,7 @@ class PagesDescriptionViewSet(BaseViewSet):
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.name = request.data.get("name", page.name)
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
+4 -5
View File
@@ -41,7 +41,6 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
class ProjectViewSet(BaseViewSet):
serializer_class = ProjectListSerializer
model = Project
@@ -342,7 +341,7 @@ class ProjectViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
except Workspace.DoesNotExist:
return Response(
@@ -351,7 +350,7 @@ class ProjectViewSet(BaseViewSet):
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
def partial_update(self, request, slug, pk=None):
@@ -420,7 +419,7 @@ class ProjectViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
@@ -429,7 +428,7 @@ class ProjectViewSet(BaseViewSet):
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
def destroy(self, request, slug, pk):
+1 -1
View File
@@ -432,7 +432,7 @@ class IssueViewViewSet(BaseViewSet):
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueViewSerializer(issue_view)
+1 -1
View File
@@ -29,7 +29,7 @@ class WebhookEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"error": "URL already exists for the workspace"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
raise IntegrityError
+6 -7
View File
@@ -119,9 +119,7 @@ class WorkSpaceViewSet(BaseViewSet):
)
# Get total members and role
total_members = WorkspaceMember.objects.filter(
workspace_id=serializer.data["id"]
).count()
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
data = serializer.data
data["total_members"] = total_members
data["role"] = 20
@@ -136,7 +134,7 @@ class WorkSpaceViewSet(BaseViewSet):
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
@@ -169,9 +167,10 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.values("count")
)
role = WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member=request.user, is_active=True
).values("role")
role = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
.values("role")
)
workspace = (
Workspace.objects.prefetch_related(
@@ -32,7 +32,7 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.uuid import is_valid_uuid
from plane.utils.valid_uuid import is_valid_uuid
# Track Changes in name
@@ -307,10 +307,6 @@ def track_labels(
# Set of newly added labels
for added_label in added_labels:
# validate uuids
if not is_valid_uuid(added_label):
continue
label = Label.objects.get(pk=added_label)
issue_activities.append(
IssueActivity(
@@ -331,10 +327,6 @@ def track_labels(
# Set of dropped labels
for dropped_label in dropped_labels:
# validate uuids
if not is_valid_uuid(dropped_label):
continue
label = Label.objects.get(pk=dropped_label)
issue_activities.append(
IssueActivity(
@@ -381,10 +373,6 @@ def track_assignees(
bulk_subscribers = []
for added_asignee in added_assignees:
# validate uuids
if not is_valid_uuid(added_asignee):
continue
assignee = User.objects.get(pk=added_asignee)
issue_activities.append(
IssueActivity(
@@ -418,10 +406,6 @@ def track_assignees(
)
for dropped_assignee in dropped_assginees:
# validate uuids
if not is_valid_uuid(dropped_assignee):
continue
assignee = User.objects.get(pk=dropped_assignee)
issue_activities.append(
IssueActivity(
@@ -1,102 +0,0 @@
# Django imports
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Max
from django.db import connection, transaction
# Module imports
from plane.db.models import Project, Issue, IssueSequence
from plane.utils.uuid import convert_uuid_to_integer
class Command(BaseCommand):
help = "Fix duplicate sequences"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("issue_identifier", type=str, help="Issue Identifier")
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 handle(self, *args, **options):
workspace_slug = input("Workspace slug: ")
if not workspace_slug:
raise CommandError("Workspace slug is required")
issue_identifier = options.get("issue_identifier", False)
# Validate issue_identifier
if not issue_identifier:
raise CommandError("Issue identifier is required")
# Validate issue identifier
try:
identifier = issue_identifier.split("-")
if len(identifier) != 2:
raise ValueError("Invalid issue identifier format")
project_identifier = identifier[0]
issue_sequence = self.strict_str_to_int(identifier[1])
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier, workspace__slug=workspace_slug
)
# Get the issues
issues = Issue.objects.filter(project=project, sequence_id=issue_sequence)
# Check if there are duplicate issues
if not issues.count() > 1:
raise CommandError(
"No duplicate issues found with the given identifier"
)
self.stdout.write(
self.style.SUCCESS(
f"{issues.count()} issues found with identifier {issue_identifier}"
)
)
with transaction.atomic():
# This ensures only one transaction per project can execute this code at a time
lock_key = convert_uuid_to_integer(project.id)
# Acquire an exclusive lock using the project ID as the lock key
with connection.cursor() as cursor:
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
# Get the maximum sequence ID for the project
last_sequence = IssueSequence.objects.filter(project=project).aggregate(
largest=Max("sequence")
)["largest"]
bulk_issues = []
bulk_issue_sequences = []
issue_sequence_map = {
isq.issue_id: isq
for isq in IssueSequence.objects.filter(project=project)
}
# change the ids of duplicate issues
for index, issue in enumerate(issues[1:]):
updated_sequence_id = last_sequence + index + 1
issue.sequence_id = updated_sequence_id
bulk_issues.append(issue)
# Find the same issue sequence instance from the above queryset
sequence_identifier = issue_sequence_map.get(issue.id)
if sequence_identifier:
sequence_identifier.sequence = updated_sequence_id
bulk_issue_sequences.append(sequence_identifier)
Issue.objects.bulk_update(bulk_issues, ["sequence_id"])
IssueSequence.objects.bulk_update(bulk_issue_sequences, ["sequence"])
self.stdout.write(self.style.SUCCESS("Sequence IDs updated successfully"))
except Exception as e:
raise CommandError(str(e))
@@ -1,31 +0,0 @@
# Generated by Django 4.2.17 on 2025-03-04 19:29
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0092_alter_deprecateddashboardwidget_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="page",
name="moved_to_page",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="page",
name="moved_to_project",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="pageversion",
name="sub_pages_data",
field=models.JSONField(blank=True, default=dict),
),
]
+1 -1
View File
@@ -82,4 +82,4 @@ from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
from .sticky import Sticky
+7 -14
View File
@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction, connection
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
from django import apps
@@ -15,8 +15,8 @@ from django import apps
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .base import BaseModel
from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
def get_default_properties():
@@ -209,18 +209,11 @@ class Issue(ProjectBaseModel):
if self._state.adding:
with transaction.atomic():
# Create a lock for this specific project using an advisory lock
# This ensures only one transaction per project can execute this code at a time
lock_key = convert_uuid_to_integer(self.project.id)
with connection.cursor() as cursor:
# Get an exclusive lock using the project ID as the lock key
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
# Get the last sequence for the project
last_sequence = IssueSequence.objects.filter(
project=self.project
).aggregate(largest=models.Max("sequence"))["largest"]
last_sequence = (
IssueSequence.objects.filter(project=self.project)
.select_for_update()
.aggregate(largest=models.Max("sequence"))["largest"]
)
self.sequence_id = last_sequence + 1 if last_sequence else 1
# Strip the html tags using html parser
self.description_stripped = (
-3
View File
@@ -50,8 +50,6 @@ class Page(BaseModel):
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
class Meta:
verbose_name = "Page"
@@ -174,7 +172,6 @@ class PageVersion(BaseModel):
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
sub_pages_data = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Page Version"
@@ -109,5 +109,5 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
if "already exists" in str(e):
return Response(
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_409_CONFLICT,
status=status.HTTP_410_GONE,
)
-71
View File
@@ -1,71 +0,0 @@
# Python imports
import logging
import time
# Django imports
from django.http import HttpRequest
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
api_logger = logging.getLogger("plane.api")
class RequestLoggerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def _should_log_route(self, request: Request | HttpRequest) -> bool:
"""
Determines whether a route should be logged based on the request and status code.
"""
# Don't log health checks
if request.path == "/" and request.method == "GET":
return False
return True
def __call__(self, request):
# get the start time
start_time = time.time()
# Get the response
response = self.get_response(request)
# calculate the duration
duration = time.time() - start_time
# Check if logging is required
log_true = self._should_log_route(request=request)
# If logging is not required, return the response
if not log_true:
return response
user_id = (
request.user.id
if getattr(request, "user")
and getattr(request.user, "is_authenticated", False)
else None
)
user_agent = request.META.get("HTTP_USER_AGENT", "")
# Log the request information
api_logger.info(
f"{request.method} {request.get_full_path()} {response.status_code}",
extra={
"path": request.path,
"method": request.method,
"status_code": response.status_code,
"duration_ms": int(duration * 1000),
"remote_addr": get_client_ip(request),
"user_agent": user_agent,
"user_id": user_id,
},
)
# return the response
return response
-1
View File
@@ -59,7 +59,6 @@ MIDDLEWARE = [
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
"plane.middleware.logger.RequestLoggerMiddleware",
]
# Rest Framework settings
+6 -11
View File
@@ -37,31 +37,26 @@ if not os.path.exists(LOG_DIR):
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
}
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "json",
"formatter": "verbose",
}
},
"loggers": {
"plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.exception": {
"level": "ERROR",
"django.request": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
"plane": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
},
}
+10 -13
View File
@@ -26,10 +26,11 @@ if not os.path.exists(LOG_DIR):
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
@@ -39,7 +40,7 @@ LOGGING = {
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
"formatter": "verbose",
"level": "INFO",
},
"file": {
@@ -58,19 +59,15 @@ LOGGING = {
},
},
"loggers": {
"plane.api": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"django": {"handlers": ["console", "file"], "level": "INFO", "propagate": True},
"django.request": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"plane.worker": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.exception": {
"plane": {
"level": "DEBUG" if DEBUG else "ERROR",
"handlers": ["console"],
"handlers": ["console", "file"],
"propagate": False,
},
},
+1 -1
View File
@@ -41,4 +41,4 @@ class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest):
response = self.client.post(
url, {"name": "Plane", "slug": "pla-ne"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.assertEqual(response.status_code, status.HTTP_410_GONE)
+2 -2
View File
@@ -8,8 +8,8 @@ from django.conf import settings
def log_exception(e):
# Log the error
logger = logging.getLogger("plane.exception")
logger.error(str(e))
logger = logging.getLogger("plane")
logger.error(e)
if settings.DEBUG:
# Print the traceback if in debug mode
-22
View File
@@ -1,22 +0,0 @@
# Python imports
import uuid
import hashlib
def is_valid_uuid(uuid_str):
"""Check if a string is a valid UUID version 4"""
try:
uuid_obj = uuid.UUID(uuid_str)
return uuid_obj.version == 4
except ValueError:
return False
def convert_uuid_to_integer(uuid_val: uuid.UUID) -> int:
"""Convert a UUID to a 64-bit signed integer"""
# Ensure UUID is a string
uuid_value: str = str(uuid_val)
# Hash to 64-bit signed int
h: bytes = hashlib.sha256(uuid_value.encode()).digest()
bigint: int = int.from_bytes(h[:8], byteorder="big", signed=True)
return bigint
+8
View File
@@ -0,0 +1,8 @@
import uuid
def is_valid_uuid(uuid_str):
try:
uuid.UUID(uuid_str, version=4)
return True
except ValueError:
return False
+1 -1
View File
@@ -43,7 +43,7 @@ scout-apm==3.1.0
# xlsx generation
openpyxl==3.1.2
# logging
python-json-logger==3.3.0
python-json-logger==2.0.7
# html parser
beautifulsoup4==4.12.3
# analytics
+16 -13
View File
@@ -3,13 +3,15 @@
"version": "0.25.3",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
"main": "./dist/start.js",
"module": "./dist/start.mjs",
"types": "./dist/start.d.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"dev": "tsup --watch --onSuccess 'node --env-file=.env dist/start.js'",
"build": "tsup",
"start": "node --env-file=.env dist/start.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
@@ -20,15 +22,19 @@
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@hocuspocus/transformer": "^2.15.2",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
"@plane/logger": "*",
"@plane/types": "*",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
@@ -37,27 +43,24 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"reflect-metadata": "^0.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.20",
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "^8.4.0",
"tsup": "8.3.0",
"typescript": "5.3.3"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./register";
@@ -0,0 +1,107 @@
import { PageService } from "@/core/services/page.service";
import { transformHTMLToBinary } from "./transformers";
import { getAllDocumentFormatsFromBinaryData } from "@/core/helpers/page";
import { logger } from "@plane/logger";
import { HocusPocusServerContext } from "@/core/types/common";
const pageService = new PageService();
/**
* Fetches the binary description data for a project page
* Falls back to HTML transformation if binary is not available
*/
export const fetchPageDescriptionBinary = async ({
pageId,
context,
}: {
pageId: string;
context: HocusPocusServerContext;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return null;
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await transformHTMLToBinary(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
}
return binaryData;
};
/**
* Updates the description of a project page
*/
export const updatePageDescription = async ({
context,
pageId,
state: updatedDescription,
title,
}: {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
}) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
export const fetchProjectPageTitle = async ({
context,
pageId,
}: {
context: HocusPocusServerContext;
pageId: string;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
return pageDetails.name;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
export const updateProjectPageTitle = async ({
context,
pageId,
title,
abortSignal,
}: {
context: HocusPocusServerContext;
pageId: string;
title: string;
abortSignal?: AbortSignal;
}) => {
const { workspaceSlug, projectId, cookie } = context;
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {
name: title,
};
await pageService.updateTitle(workspaceSlug, projectId, pageId, payload, cookie, abortSignal);
};
@@ -0,0 +1,3 @@
export * from "./handlers"
export * from "./transformers";
export * from "./project-page-handler";
@@ -0,0 +1,47 @@
import {
DocumentHandler,
DocumentFetchParams,
DocumentStoreParams,
HandlerDefinition,
} from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import {
fetchPageDescriptionBinary,
updatePageDescription,
fetchProjectPageTitle,
updateProjectPageTitle,
} from "./handlers";
/**
* Handler for "project_page" document type
*/
export const projectPageHandler: DocumentHandler = {
/**
* Fetch project page description
*/
fetch: fetchPageDescriptionBinary,
/**
* Store project page description
*/
store: updatePageDescription,
/**
* Fetch project page title
*/
fetchTitle: fetchProjectPageTitle,
/**
* Store project page title
*/
updateTitle: updateProjectPageTitle,
};
// Define the project page handler definition
export const projectPageHandlerDefinition: HandlerDefinition = {
selector: (context) => context.documentType === "project_page",
handler: projectPageHandler,
priority: 10, // Standard priority
};
// Register the handler directly from CE
export function registerProjectPageHandler() {
handlerFactory.register(projectPageHandlerDefinition);
}
@@ -0,0 +1,26 @@
import { PageService } from "@/core/services/page.service";
import { getBinaryDataFromHTMLString } from "@/core/helpers/page";
import logger from "@plane/logger";
const pageService = new PageService();
/**
* Transforms HTML description to binary format
*/
export const transformHTMLToBinary = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
+5
View File
@@ -0,0 +1,5 @@
import { registerProjectPageHandler } from "./project-page";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
}
+18 -7
View File
@@ -1,14 +1,25 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
documentType: TDocumentTypes | undefined;
interface TArgs {
cookie: string;
documentType: TDocumentTypes;
pageId: string;
params: URLSearchParams;
}
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
const { documentType } = args;
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
}
const { cookie, documentType, pageId, params } = args;
if (!documentType) {
throw new AppError("Document type is required");
}
if (!pageId) {
throw new AppError("Page ID is required");
}
return null
};
+1 -1
View File
@@ -1,5 +1,5 @@
// types
import { TDocumentTypes } from "@/core/types/common.js";
import { TDocumentTypes } from "@/core/types/common";
type TArgs = {
cookie: string | undefined;
+1 -1
View File
@@ -1 +1 @@
export type TAdditionalDocumentTypes = {};
export type TAdditionalDocumentTypes = string;
+66
View File
@@ -0,0 +1,66 @@
import { env } from "@/env";
import compression from "compression";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express";
import { logger } from "@plane/logger";
import { logger as loggerMiddleware } from "@/core/helpers/logger";
/**
* Configure server middleware
* @param app Express application
*/
export function configureServerMiddleware(app: express.Application): void {
// Security middleware
app.use(helmet());
// CORS configuration
configureCors(app);
// Compression middleware
app.use(
compression({
level: env.COMPRESSION_LEVEL,
threshold: env.COMPRESSION_THRESHOLD,
}) as unknown as express.RequestHandler
);
// Cookie parsing
app.use(cookieParser());
// Logging middleware
app.use(loggerMiddleware);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
}
/**
* Configure CORS
* @param app Express application
*/
function configureCors(app: express.Application): void {
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
for (const origin of origins) {
logger.info(`Adding CORS allowed origin: ${origin}`);
app.use(
cors({
origin,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
})
);
}
}
/**
* Server configuration
*/
export const serverConfig = {
port: env.PORT,
basePath: env.LIVE_BASE_PATH,
terminationTimeout: env.SHUTDOWN_TIMEOUT,
};
+21
View File
@@ -0,0 +1,21 @@
import { HealthController } from "@/core/controllers/health.controller";
import { DocumentController } from "@/core/controllers/document.controller";
import { CollaborationController } from "@/core/controllers/collaboration.controller";
/**
* Controller registry exports
* Simple grouped arrays of controller classes for better organization
*/
export const CONTROLLERS = {
// Core system controllers (health checks, status endpoints)
CORE: [HealthController],
// Document management controllers
DOCUMENT: [DocumentController],
// WebSocket controllers for real-time functionality
WEBSOCKET: [CollaborationController],
};
// Helper to get all REST controllers
export const getAllControllers = () => [...CONTROLLERS.CORE, ...CONTROLLERS.DOCUMENT, ...CONTROLLERS.WEBSOCKET];
@@ -0,0 +1,96 @@
import type { Request } from "express";
import type { WebSocket as WS } from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
import { ErrorCategory } from "@/core/helpers/error-handling/error-handler";
import { logger } from "@plane/logger";
import Errors from "@/core/helpers/error-handling/error-factory";
import { Controller, WebSocket } from "@plane/decorators";
@Controller("/collaboration")
export class CollaborationController {
private metrics = {
errors: 0,
};
constructor(private readonly hocusPocusServer: Hocuspocus) {}
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId: req.id || crypto.randomUUID(),
};
try {
// Initialize the connection with Hocuspocus
this.hocusPocusServer.handleConnection(ws, req);
// Set up error handling for the connection
ws.on("error", (error) => {
this.handleConnectionError(error, clientInfo, ws);
});
} catch (error) {
this.handleConnectionError(error, clientInfo, ws);
}
}
private handleConnectionError(error: unknown, clientInfo: Record<string, any>, ws: WS) {
// Convert to AppError if needed
const appError = Errors.convertError(error instanceof Error ? error : new Error(String(error)), {
context: {
...clientInfo,
component: "WebSocketConnection",
},
});
// Log at appropriate level based on error category
if (appError.category === ErrorCategory.OPERATIONAL) {
logger.info(`WebSocket operational error: ${appError.message}`, {
error: appError,
clientInfo,
});
} else {
logger.error(`WebSocket error: ${appError.message}`, {
error: appError,
clientInfo,
stack: appError.stack,
});
}
// Alert if error threshold is reached
if (this.metrics.errors % 10 === 0) {
logger.warn(`High WebSocket error rate detected: ${this.metrics.errors} total errors`);
}
// Try to send error to client before closing
try {
if (ws.readyState === ws.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: appError.category === ErrorCategory.OPERATIONAL ? appError.message : "Internal server error",
})
);
}
} catch (sendError) {
// Ignore send errors at this point
}
// Close with informative message if connection is still open
if (ws.readyState === ws.OPEN) {
ws.close(
1011,
appError.category === ErrorCategory.OPERATIONAL
? `Error: ${appError.message}. Reconnect with exponential backoff.`
: "Internal server error. Please retry in a few moments."
);
}
}
getErrorMetrics() {
return {
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,128 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common";
// decorators
import { CatchErrors } from "@/lib/decorators";
// logger
import { logger } from "@plane/logger";
import { Controller, Post } from "@plane/decorators";
import { AppError } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
// Define the schema with more robust validation
const convertDocumentSchema = z.object({
description_html: z
.string()
.min(1, "HTML content cannot be empty")
.refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace")
.refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"),
variant: z.enum(["rich", "document"]),
});
@Controller("/convert-document")
export class DocumentController {
private metrics = {
conversions: 0,
errors: 0,
};
@Post("/")
@CatchErrors()
async convertDocument(req: Request, res: Response) {
const requestId = req.id || crypto.randomUUID();
const clientInfo = {
ip: req.ip,
userAgent: req.get("user-agent"),
requestId,
};
try {
// Validate request body
const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody);
const { description_html, variant } = validatedData;
// Log validated data
logger.info("Validated document conversion request", {
...clientInfo,
variant,
contentLength: description_html.length,
});
// Process document conversion
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
// Update metrics
this.metrics.conversions++;
// Log successful conversion
logger.info("Document conversion successful", {
...clientInfo,
variant,
outputLength: description_html.length,
});
// Return successful response
res.status(200).json({
description,
description_binary,
});
} catch (error) {
// Update error metrics
this.metrics.errors++;
let appError: AppError;
if (error instanceof z.ZodError) {
// Handle validation errors
appError = handleError(error, {
errorType: "unprocessable-entity",
message: "Invalid request data",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: {
...clientInfo,
validationErrors: error.errors.map((err) => ({
path: err.path.join("."),
message: err.message,
})),
},
});
} else {
// Handle other errors
appError = handleError(error, {
errorType: "internal",
message: "Internal server error",
component: "document-conversion-controller",
operation: "convertDocument",
extraContext: clientInfo,
});
}
// Log the error
logger.error("Document conversion failed", {
error: appError,
status: appError.status,
context: appError.context,
});
res.status(appError.status).json({
message: appError.message,
status: appError.status,
context: appError.context,
});
}
}
getMetrics() {
return {
conversions: this.metrics.conversions,
errors: this.metrics.errors,
};
}
}
@@ -0,0 +1,16 @@
import { CatchErrors } from "@/lib/decorators";
import { Controller, Get } from "@plane/decorators";
import type { Request, Response } from "express";
@Controller("/health")
export class HealthController {
@Get("/")
@CatchErrors()
async healthCheck(_req: Request, res: Response) {
res.status(200).json({
status: "OK",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "1.0.0",
});
}
}
+4
View File
@@ -0,0 +1,4 @@
// Export all controllers from this barrel file
export { HealthController } from "./health.controller";
export { CollaborationController } from "./collaboration.controller";
export { DocumentController } from "./document.controller";
+114
View File
@@ -0,0 +1,114 @@
import { Database } from "@hocuspocus/extension-database";
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getDocumentHandler } from "../handlers/document-handlers";
import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common";
import { storePayload } from "@hocuspocus/server";
import { extractTextFromHTML } from "./title-update/title-utils";
export const createDatabaseExtension = () => {
return new Database({
fetch: handleFetch,
store: handleStore,
});
};
const handleFetch = async ({
context,
documentName: pageId,
}: {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
}) => {
const { documentType } = context;
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 documentHandler = getDocumentHandler(context);
fetchedData = await documentHandler.fetch({
context: context as HocusPocusServerContext,
pageId,
});
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: context.documentType },
extra: { operation: "fetch" },
}
)();
return fetchedData;
};
const handleStore = async ({
context,
state,
documentName: pageId,
document,
}: Partial<storePayload> & {
context: HocusPocusServerContext;
documentName: TDocumentTypes;
}) => {
catchAsync(
async () => {
if (!state) {
handleError(null, {
errorType: "bad-request",
message: "Loaded binary state is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
let title = "";
if (document) {
title = extractTextFromHTML(document?.getXmlFragment("title")?.toJSON());
}
const { documentType } = context as HocusPocusServerContext;
if (!documentType) {
handleError(null, {
errorType: "bad-request",
message: "Document type is required",
component: "database-extension",
operation: "store",
extraContext: { pageId },
throw: true,
});
}
const documentHandler = getDocumentHandler(context);
await documentHandler.store({
context: context as HocusPocusServerContext,
pageId,
state,
title,
});
},
{
params: { pageId, documentType: context.documentType },
extra: { operation: "store" },
}
)();
};
+13 -128
View File
@@ -1,143 +1,28 @@
// Third-party libraries
import { Redis } from "ioredis";
// Hocuspocus extensions and core
import { Database } from "@hocuspocus/extension-database";
// hocuspocus extensions and core
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
fetchPageDescriptionBinary,
updatePageDescription,
} from "@/core/lib/page.js";
// plane live libraries
import { fetchDocument } from "@/plane-live/lib/fetch-document.js";
import { updateDocument } from "@/plane-live/lib/update-document.js";
// types
import {
type HocusPocusServerContext,
type TDocumentTypes,
} from "@/core/types/common.js";
import { setupRedisExtension } from "@/core/extensions/redis";
import { createDatabaseExtension } from "@/core/extensions/database";
import { logger } from "@plane/logger";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions: () => Promise<Extension[]> = async () => {
export const getExtensions = async (): Promise<Extension[]> => {
const extensions: Extension[] = [
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
},
}),
new Database({
fetch: async ({ context, documentName: pageId, requestParameters }) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie,
);
} else {
fetchedData = await fetchDocument({
cookie,
documentType,
pageId,
params,
});
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
}
});
},
store: async ({
context,
state,
documentName: pageId,
requestParameters,
}) => {
const cookie = (context as HocusPocusServerContext).cookie;
// query params
const params = requestParameters;
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
await updatePageDescription(params, pageId, state, cookie);
} else {
await updateDocument({
cookie,
documentType,
pageId,
params,
updatedDescription: state,
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
}
});
logger.info(message);
},
}),
createDatabaseExtension(),
];
const redisUrl = getRedisUrl();
const titleSyncExtension = new TitleSyncExtension();
extensions.push(titleSyncExtension);
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)",
);
}
// Add Redis extensions if Redis is available
const redisExtensions = await setupRedisExtension();
extensions.push(...redisExtensions);
return extensions;
};
+41
View File
@@ -0,0 +1,41 @@
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
import { Extension } from "@hocuspocus/server";
// core helpers and utilities
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
/**
* Sets up the Redis extension for HocusPocus using the RedisManager singleton
* @returns Promise that resolves to a Redis extension array
*/
export const setupRedisExtension = async (): Promise<Extension[]> => {
const extensions: Extension[] = [];
const redisManager = RedisManager.getInstance();
// Wait for Redis connection
const redisClient = await redisManager.connect();
if (redisClient) {
extensions.push(
new HocusPocusRedis({
redis: redisClient,
})
);
logger.info("HocusPocus Redis extension configured ✅");
} else {
logger.warn(
"Redis connection failed, continuing without Redis extension (you won't be able to sync data between multiple plane live servers)"
);
}
return extensions;
};
/**
* Helper to get the current Redis status
* Useful for health checks
*/
export const getRedisStatus = (): "connected" | "connecting" | "disconnected" | "not-configured" => {
const redisManager = RedisManager.getInstance();
return redisManager.getStatus();
};
+124
View File
@@ -0,0 +1,124 @@
// hocuspocus
import { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
// types
import { HocusPocusServerContext } from "@/core/types/common";
// editor extensions
import { TITLE_EDITOR_EXTENSIONS } from "@plane/editor";
// handlers
import { getDocumentHandler } from "@/core/handlers/document-handlers";
// helpers
import { generateTitleProsemirrorJson } from "@/core/helpers/generate-title-prosemirror-json";
import { extractTextFromHTML } from "./title-update/title-utils";
import { TitleUpdateManager } from "./title-update/title-update-manager";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
instance!: Hocuspocus;
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
async onLoadDocument({ context, document }: { context: HocusPocusServerContext; document: Document }) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const { workspaceSlug, projectId } = context;
const documentHandler = getDocumentHandler(context);
if (!workspaceSlug || !projectId) return;
const title = await documentHandler.fetchTitle({
context,
pageId: document.name,
});
if (title == null) return;
const titleField = TiptapTransformer.toYdoc(
generateTitleProsemirrorJson(title),
"title",
TITLE_EDITOR_EXTENSIONS
);
document.merge(titleField);
}
} catch (error) {
console.error("Error in onLoadDocument: ", error);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
}) {
const { workspaceSlug, projectId } = context;
// Exit if we don't have the required information
if (!workspaceSlug || !projectId) return;
const documentHandler = getDocumentHandler(context);
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, documentHandler, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Set up observer for title field
const titleObserver = (events: Y.YEvent<any>[]) => {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON());
});
// Schedule an update with the manager
const manager = this.titleUpdateManagers.get(documentName);
if (manager) {
manager.scheduleUpdate(title);
}
};
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName }: { documentName: string }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
this.titleObservers.delete(documentName);
}
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -0,0 +1,342 @@
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: NodeJS.Timeout | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation in progress, storing new args and starting new timer`);
}
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Already scheduled, updating args and restarting timer`);
}
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Scheduled execution with wait time ${this.wait}ms`);
}
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// If an operation is already in progress, abort it if the debounce period has completed
if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Timer expired while operation in progress - will abort current operation`);
}
}
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
console.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Starting operation`);
}
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
if (this.logPrefix) {
console.log(`${this.logPrefix}: Completed operation`);
}
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args have not changed during operation, clearing lastArgs`);
}
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Args changed during operation, ensuring timer is running`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Operation was aborted, another operation should be starting`);
}
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
console.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Rescheduling failed operation`);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Aborting in-progress operation`);
}
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force resetting in-progress state after abort`);
}
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Force immediate execution`);
}
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
} else if (this.state.inProgress) {
if (this.logPrefix) {
console.log(`${this.logPrefix}: No new args to process, letting current operation complete`);
}
} else {
if (this.logPrefix) {
console.log(`${this.logPrefix}: No args to process`);
}
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
if (this.logPrefix) {
console.log(`${this.logPrefix}: Cancelling pending operations`);
}
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -0,0 +1,96 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { DebounceManager } from "./debounce";
import { HocusPocusServerContext } from "@/core/types/common";
import { env } from "@/env";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private documentHandler: DocumentHandler;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
private context: HocusPocusServerContext;
/**
* Create a new TitleUpdateManager instance
*/
constructor(
documentName: string,
documentHandler: DocumentHandler,
context: HocusPocusServerContext,
wait: number = 3000
) {
this.context = context;
this.documentName = documentName;
this.documentHandler = documentHandler;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: env.NODE_ENV === "development" ? `TitleManager[${documentName.substring(0, 8)}]` : "",
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
if (!this.documentHandler.updateTitle) {
console.log(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await this.documentHandler.updateTitle({
context: this.context,
pageId: this.documentName,
title,
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
if (error instanceof Error && !(error.name === "AbortError")) {
console.error(`Error updating title for ${this.documentName}:`, error);
}
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
@@ -0,0 +1,8 @@
/**
* Utility function to extract text from HTML content
*/
export const extractTextFromHTML = (html: string): string => {
// Use a regex to extract text between tags
const textMatch = html.replace(/<[^>]*>/g, "");
return textMatch || "";
};
@@ -0,0 +1,32 @@
import { DocumentHandler, HandlerContext, HandlerDefinition } from "@/core/types/document-handler";
/**
* Class that manages handler selection based on multiple criteria
*/
export class DocumentHandlerFactory {
private handlers: HandlerDefinition[] = [];
/**
* Register a handler with its selection criteria
*/
register(definition: HandlerDefinition): void {
this.handlers.push(definition);
// Sort handlers by priority (highest first)
this.handlers.sort((a, b) => b.priority - a.priority);
}
/**
* Get the appropriate handler based on the provided context
*/
getHandler(context: HandlerContext): DocumentHandler {
// Find the first handler whose selector returns true
const matchingHandler = this.handlers.find(h => h.selector(context));
// Return the matching handler or fall back to null/undefined
// (This will cause an error if no handlers match, which is good for debugging)
return matchingHandler?.handler as DocumentHandler;
}
}
// Create the singleton instance
export const handlerFactory = new DocumentHandlerFactory();
@@ -0,0 +1,21 @@
import { DocumentHandler } from "@/core/types/document-handler";
import { handlerFactory } from "@/core/handlers/document-handlers/handler-factory";
import { HocusPocusServerContext } from "@/core/types/common";
import { initializeDocumentHandlers } from "@/plane-live/document-types";
// initialize all document handlers
initializeDocumentHandlers();
/**
* 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(context: HocusPocusServerContext): DocumentHandler {
return handlerFactory.getHandler(context);
}
// Export the factory for direct access if needed
export { handlerFactory };
-21
View File
@@ -1,21 +0,0 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
// Send the response
res.json({
error: {
message:
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message,
...(process.env.NODE_ENV !== "production" && { stack: err.stack }),
},
});
};
@@ -0,0 +1,276 @@
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 = {
"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),
},
} satisfies Record<string, ErrorFactory>;
// 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 {
// Only throw if throw is explicitly true
const shouldThrow = (options as ThrowingOptions).throw === true;
// If the error is already an AppError and we want to rethrow it as is
if (options.rethrowIfAppError !== false && error instanceof AppError) {
if (shouldThrow) {
throw error;
}
return error;
}
// Format the error message
const errorMessage = options.message
? error instanceof Error
? `${options.message}: ${error.message}`
: error
? `${options.message}: ${String(error)}`
: options.message
: error instanceof Error
? error.message
: error
? String(error)
: "Unknown error occurred";
// Build context object
const context = {
component: options.component,
operation: options.operation,
originalError: error,
...(options.extraContext || {}),
};
// Create the appropriate error type using our factory map
const errorType = options.errorType || "internal";
const factory = ERROR_FACTORIES[errorType];
if (!factory) {
// If no factory found, default to internal error
return ERROR_FACTORIES.internal.createError(errorMessage, context);
}
// Create the error with the factory
const appError = factory.createError(errorMessage, context);
// If we should throw, do so now
if (shouldThrow) {
throw appError;
}
return appError;
}
/**
* Utility function to convert errors or enhance existing AppErrors
*/
export const convertError = (
error: Error,
options?: {
statusCode?: number;
message?: string;
category?: ErrorCategory;
context?: Record<string, any>;
}
): AppError => {
if (error instanceof AppError) {
// If it's already an AppError and no overrides, return as is
if (!options?.statusCode && !options?.message && !options?.category) {
return error;
}
// Create a new AppError with the original as context
return new AppError(
options?.message || error.message,
options?.statusCode || error.status,
options?.category || error.category,
{
...(error.context || {}),
...(options?.context || {}),
originalError: error,
}
);
}
// Determine the appropriate error type based on status code
let errorType: ErrorType = "internal";
if (options?.statusCode) {
// Find the error type that matches the status code
const entry = Object.entries(ERROR_FACTORIES).find(([_, factory]) => factory.statusCode === options.statusCode);
if (entry) {
errorType = entry[0] as ErrorType;
}
}
// Return a new AppError using the factory
return handleError(error, {
errorType: errorType,
message: options?.message,
component: options?.context?.component || "unknown",
operation: options?.context?.operation || "convert-error",
extraContext: options?.context,
});
};
/**
* Check if an error is an AppError
*/
export const isAppError = (err: any, statusCode?: number): boolean => {
return err instanceof AppError && (!statusCode || err.status === statusCode);
};
// Export only the public API
export default {
handleError,
convertError,
isAppError,
};
@@ -0,0 +1,384 @@
import { ErrorRequestHandler, Request, Response, NextFunction } from "express";
import { env } from "@/env";
import { logger } from "@plane/logger";
import { handleError } from "./error-factory";
import { ErrorContext, reportError } from "./error-reporting";
import { manualLogger } from "../logger";
/**
* 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,
});
}
}
}
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);
// For fatal errors, log but NEVER terminate the app
if (error.category === ErrorCategory.FATAL) {
logger.error(`FATAL ERROR OCCURRED BUT APP WILL CONTINUE RUNNING: ${error.message}`);
}
};
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;
}
};
};
/**
* Set up global error handlers for uncaught exceptions and unhandled rejections
* @param gracefulTerminationHandler Function to call for graceful termination
*/
export const setupGlobalErrorHandlers = (gracefulTerminationHandler: () => Promise<void>): void => {
// Handle promise rejections
process.on("unhandledRejection", (reason: unknown) => {
logger.error("Unhandled Promise Rejection", { 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" },
});
// Log the error but never terminate
logger.error(`Unhandled rejection caught and contained: ${appError.message}`);
});
// Handle exceptions
process.on("uncaughtException", (error: Error) => {
logger.error("Uncaught Exception", {
error: error.message,
stack: error.stack,
});
// Convert to AppError if needed
const appError = handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: {
source: "uncaughtException",
},
});
// Log the error but never terminate
logger.warn(`Uncaught exception contained: ${appError.message}`);
});
// Handle termination signals
process.on("SIGTERM", () => {
logger.info("SIGTERM received. Starting graceful termination...");
gracefulTerminationHandler();
});
process.on("SIGINT", () => {
logger.info("SIGINT received. Starting graceful termination...");
gracefulTerminationHandler();
});
};
/**
* Configure error handling middleware for the Express app
* @param app Express application instance
*/
export function configureErrorHandlers(app: any): void {
// Global error handling middleware
app.use(errorHandler);
// 404 handler must be last
app.use((_req: Request, _res: Response, next: NextFunction) => {
next(
handleError(null, {
errorType: "not-found",
message: "Resource not found",
component: "express",
operation: "route-handler",
extraContext: { path: _req.path },
throw: true,
})
);
});
}
@@ -0,0 +1,45 @@
import { AppError } 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,
});
};
export const handleFatalError = (error: Error | unknown, context?: ErrorContext): void => {
// Convert to fatal AppError
const fatalError = handleError(error, {
errorType: "fatal",
message: error instanceof Error ? error.message : String(error),
component: context?.extra?.component || "system",
operation: context?.extra?.operation || "fatal-error-handler",
extraContext: {
...context,
originalError: error,
},
});
process.emit("uncaughtException", fatalError);
};
@@ -0,0 +1,158 @@
import { handleError } from "./error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is required`,
component: 'validator',
operation: 'required',
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a string`,
component: 'validator',
operation: 'string',
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} cannot be empty`,
component: 'validator',
operation: 'notEmpty',
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a valid number`,
component: 'validator',
operation: 'number',
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be a non-empty array`,
component: 'validator',
operation: 'nonEmptyArray',
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} has an invalid format`,
component: 'validator',
operation: 'match',
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} must be one of: ${allowedValues.join(", ")}`,
component: 'validator',
operation: 'oneOf',
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(null, {
errorType: 'bad-request',
message: message || `${this.name} is invalid`,
component: 'validator',
operation: 'custom',
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
@@ -0,0 +1,17 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
content: [
{
type: "text",
text,
},
],
},
],
};
};
+261
View File
@@ -0,0 +1,261 @@
import { handleError } from "./error-handling/error-factory";
/**
* A simple validation utility that integrates with our error system.
*
* This provides a fluent interface for validating data and throwing
* appropriate errors if validation fails.
*/
export class Validator<T> {
constructor(
private readonly data: T,
private readonly name: string = "data"
) {}
/**
* Ensures a value is defined (not undefined or null)
*/
required(message?: string): Validator<T> {
if (this.data === undefined || this.data === null) {
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a string
*/
string(message?: string): Validator<T> {
if (typeof this.data !== "string") {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a string is not empty
*/
notEmpty(message?: string): Validator<T> {
if (typeof this.data === "string" && this.data.trim() === "") {
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value is a number
*/
number(message?: string): Validator<T> {
if (typeof this.data !== "number" || isNaN(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures an array is not empty
*/
nonEmptyArray(message?: string): Validator<T> {
if (!Array.isArray(this.data) || this.data.length === 0) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Ensures a value matches a regular expression
*/
match(regex: RegExp, message?: string): Validator<T> {
if (typeof this.data !== "string" || !regex.test(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: this.name, format: regex.toString() },
throw: true
});
}
return this;
}
/**
* Ensures a value is one of the allowed values
*/
oneOf(allowedValues: any[], message?: string): Validator<T> {
if (!allowedValues.includes(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: this.name, allowedValues },
throw: true
});
}
return this;
}
/**
* Custom validation function
*/
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
if (!validationFn(this.data)) {
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: this.name },
throw: true
});
}
return this;
}
/**
* Get the validated data
*/
get(): T {
return this.data;
}
}
/**
* Create a new validator for a value
*/
export const validate = <T>(data: T, name?: string): Validator<T> => {
return new Validator(data, name);
};
export default validate;
export class ValidationError extends Error {
constructor(public name: string, message: string) {
super(message);
this.name = name;
}
}
export const validateRequired = (value: any, name: string, message?: string) => {
if (value === undefined || value === null) {
throw handleError(new ValidationError(name, message || `${name} is required`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateRequired',
extraContext: { field: name },
throw: true
});
}
};
export const validateString = (value: any, name: string, message?: string) => {
if (typeof value !== 'string') {
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
if (!value.trim()) {
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNonEmptyString',
extraContext: { field: name },
throw: true
});
}
};
export const validateNumber = (value: any, name: string, message?: string) => {
if (typeof value !== 'number' || isNaN(value)) {
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateNumber',
extraContext: { field: name },
throw: true
});
}
};
export const validateArray = (value: any, name: string, message?: string) => {
if (!Array.isArray(value) || value.length === 0) {
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateArray',
extraContext: { field: name },
throw: true
});
}
};
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
if (!format.test(value)) {
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateFormat',
extraContext: { field: name, format: format.toString() },
throw: true
});
}
};
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
if (!allowedValues.includes(value)) {
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateEnum',
extraContext: { field: name, allowedValues },
throw: true
});
}
};
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
if (!validator(value)) {
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
errorType: 'bad-request',
component: 'validation',
operation: 'validateCustom',
extraContext: { field: name },
throw: true
});
}
};
+75 -47
View File
@@ -1,17 +1,18 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
import { IncomingHttpHeaders } from "http";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
import { handleAuthentication } from "@/core/lib/authentication";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
import { getExtensions } from "@/core/extensions/index";
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
// error handling
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
@@ -21,53 +22,80 @@ export const getHocusPocusServer = async () => {
onAuthenticate: async ({
requestHeaders,
context,
requestParameters,
// user id used as token for authentication
token,
}: {
requestHeaders: IncomingHttpHeaders;
context: HocusPocusServerContext; // Better than 'any', still allows property assignment
requestParameters: URLSearchParams;
token: string;
}) => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// need to rethrow all errors since hocuspocus needs to know to stop
// further propagation of events to other document lifecycle
return catchAsync(
async () => {
let cookie: string | undefined = undefined;
let userId: string | undefined = undefined;
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
// Extract cookie (fallback to request headers) and userId from token (for scenarios where
// the cookies are not passed in the request headers)
try {
const parsedToken = JSON.parse(token) as TUserDetails;
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
console.error("Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
cookie = requestHeaders.cookie?.toString();
}
}
if (!cookie || !userId) {
handleError(null, {
errorType: "unauthorized",
message: "Credentials not provided",
component: "hocuspocus",
operation: "authenticate",
extraContext: { tokenProvided: !!token },
throw: true,
});
}
context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes;
context.cookie = cookie ?? requestParameters.get("cookie");
context.userId = userId;
context.workspaceSlug = requestParameters.get("workspaceSlug")?.toString() as string;
context.projectId = requestParameters.get("projectId")?.toString() as string;
return await handleAuthentication({
cookie: context.cookie,
userId: context.userId,
workspaceSlug: context.workspaceSlug,
});
},
{ extra: { operation: "authenticate" } },
{
rethrow: true,
}
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
(context as HocusPocusServerContext).cookie = cookie;
try {
await handleAuthentication({
cookie,
userId,
});
} catch (error) {
throw Error("Authentication unsuccessful!");
}
)();
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
onStateless: async ({ payload, document }) => {
return catchAsync(
async () => {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
{ extra: { operation: "stateless", payload } }
);
},
extensions,
debounce: 10000,
debounce: 1000,
});
};
+27 -7
View File
@@ -1,27 +1,47 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { UserService } from "@/core/services/user.service";
import { handleError } from "@/core/helpers/error-handling/error-factory";
const userService = new UserService();
type Props = {
cookie: string;
userId: string;
workspaceSlug: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, userId } = props;
const { cookie, userId, workspaceSlug } = props;
// fetch current user info
let response;
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
throw error;
console.log("caught?");
handleError(error, {
errorType: "unauthorized",
message: "Failed to authenticate user",
component: "authentication",
operation: "fetch-current-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
if (response.id !== userId) {
throw Error("Authentication failed: Token doesn't match the current user.");
handleError(null, {
errorType: "unauthorized",
message: "Authentication failed: Token doesn't match the current user.",
component: "authentication",
operation: "validate-user",
extraContext: {
userId,
workspaceSlug,
},
throw: true,
});
}
return {
+71 -70
View File
@@ -1,112 +1,113 @@
// helpers
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { PageService } from "@/core/services/page.service";
import logger from "@plane/logger";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
params: URLSearchParams | undefined,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
title: string
) => {
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();
const workspaceSlug = params?.get("workspaceSlug")?.toString();
const projectId = params?.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
name: title,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
throw error;
}
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
};
export const fetchProjectPageTitle = async ({
workspaceSlug,
projectId,
pageId,
cookie,
}: {
workspaceSlug: string;
projectId: string;
pageId: string;
cookie: string | undefined;
}) => {
if (!workspaceSlug || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(
workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
return contentBinary;
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
return pageDetails.name;
} catch (error) {
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
export const updateProjectPageTitle = async ({
workspaceSlug,
projectId,
pageId,
title,
cookie,
abortSignal,
}: {
workspaceSlug: string;
projectId: string;
pageId: string;
title: string;
cookie: string | undefined;
abortSignal?: AbortSignal;
}) => {
if (!workspaceSlug || !projectId || !cookie) return;
const payload = {
name: title,
};
await pageService.updateTitle(workspaceSlug, projectId, pageId, payload, cookie, abortSignal);
};
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
try {
const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binaryData = new Uint8Array(response);
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
if (binary) {
return binary;
}
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
throw error;
}
return binaryData;
};
+127
View File
@@ -0,0 +1,127 @@
import { Redis } from "ioredis";
import { logger } from "@plane/logger";
import { getRedisUrl } from "@/core/lib/utils/redis-url";
import { ShutdownManager } from "@/core/shutdown-manager";
// Define Redis error interface to handle specific error properties
interface RedisError extends Error {
code?: string;
}
export class RedisManager {
private static instance: RedisManager;
private client: Redis | null = null;
private hasEverConnected = false;
private readonly maxReconnectAttempts = 3;
// Private constructor to enforce singleton pattern
private constructor() {}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
public getClient(): Redis | null {
return this.client;
}
public async connect(): Promise<Redis | 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;
}
this.client = new Redis(redisUrl, {
retryStrategy: (times: number): number | null => {
if (!this.hasEverConnected) {
// If we've never connected successfully, don't retry
logger.warn(
"Initial Redis connection attempt failed. Continuing without Redis (you won't be able to sync data between multiple plane live servers)"
);
return null;
} else {
// Once connected at least once, try a few times before giving up
if (times > this.maxReconnectAttempts) {
logger.error(`Exceeded ${this.maxReconnectAttempts} Redis reconnect attempts. Shutting down the server.`);
// Use ShutdownManager to gracefully terminate the server
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.shutdown("Redis connection lost and could not be recovered", 1);
return null; // This will never be reached due to shutdown, but needed for type safety
}
logger.warn(`Redis connection lost. Attempting to reconnect (#${times}) in 1000 ms...`);
return 1000; // wait 1 second between attempts
}
},
});
// Set up event handlers
this.client.on("connect", () => {
logger.info("Redis: connecting...");
});
this.client.on("ready", () => {
if (!this.hasEverConnected) {
logger.info("Redis: initial connection established and ready ✅");
} else {
logger.info("Redis: reconnected and ready ✅");
}
this.hasEverConnected = true;
});
this.client.on("error", (error: RedisError) => {
if (
error?.code === "ENOTFOUND" ||
error.message.includes("WRONGPASS") ||
error.message.includes("NOAUTH") ||
error.message.includes("ECONNREFUSED")
) {
if (this.client) this.client.disconnect();
}
logger.warn("Redis error:", error);
});
this.client.on("close", () => {
logger.warn("Redis connection closed.");
});
this.client.on("reconnecting", (delay: number) => {
logger.info(`Redis: reconnecting in ${delay} ms...`);
});
// Wait for connection to be ready or fail
return new Promise<Redis | null>((resolve) => {
if (!this.client) {
resolve(null);
return;
}
this.client.once("ready", () => {
resolve(this.client);
});
this.client.once("error", () => {
// The retryStrategy will handle this, we just need to resolve with null
// if initial connection fails
if (!this.hasEverConnected) {
resolve(null);
}
});
});
}
public getStatus(): "connected" | "connecting" | "disconnected" | "not-configured" {
if (!this.client) return "not-configured";
const status = this.client.status;
if (status === "ready") return "connected";
if (status === "connect" || status === "reconnecting") return "connecting";
return "disconnected";
}
}
+84
View File
@@ -0,0 +1,84 @@
// server
import { Server } from "http";
// hocuspocus server
import type { Hocuspocus } from "@hocuspocus/server";
// logger
import { logger } from "@plane/logger";
// config
// error handling
import { handleError } from "@/core/helpers/error-handling/error-factory";
// shutdown manager
import { ShutdownManager } from "@/core/shutdown-manager";
/**
* ProcessManager handles graceful process termination and resource cleanup
*/
export class ProcessManager {
private readonly hocusPocusServer: Hocuspocus;
private readonly httpServer: Server;
private shutdownManager: ShutdownManager;
/**
* Initialize the process manager
* @param hocusPocusServer Hocuspocus server instance
* @param httpServer HTTP server instance
*/
constructor(hocusPocusServer: Hocuspocus, httpServer: Server) {
this.hocusPocusServer = hocusPocusServer;
this.httpServer = httpServer;
this.shutdownManager = ShutdownManager.getInstance();
}
/**
* Register process termination signal handlers
*/
registerTerminationHandlers(): void {
const gracefulTermination = this.getGracefulTerminationHandler();
// Handle process signals
process.on("SIGTERM", gracefulTermination);
process.on("SIGINT", gracefulTermination);
// Handle uncaught exceptions - create AppError but DON'T terminate
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception:", error);
// Create AppError to track the issue but don't terminate
handleError(error, {
errorType: "internal",
component: "process",
operation: "uncaughtException",
extraContext: { source: "uncaughtException" },
});
});
// Handle unhandled promise rejections - create AppError but DON'T terminate
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled rejection:", reason);
// Create AppError to track the issue but don't terminate
handleError(reason, {
errorType: "internal",
component: "process",
operation: "unhandledRejection",
extraContext: { source: "unhandledRejection" },
});
});
}
/**
* Get the graceful termination handler
* @returns Termination function
*/
private getGracefulTerminationHandler(): () => Promise<void> {
return async () => {
// Check if ShutdownManager is already handling a shutdown
if (this.shutdownManager.isShutdownInProgress()) {
logger.info("Shutdown already in progress via ShutdownManager, deferring to its process");
return;
}
logger.info("Signal received, delegating to ShutdownManager for graceful termination");
await this.shutdownManager.shutdown("Process termination signal received", 1);
};
}
}
+74 -40
View File
@@ -1,55 +1,92 @@
// types
import { TPage } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class PageService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchDetails(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
{
headers: {
Cookie: cookie,
},
}
)
async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<TPage> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchDescriptionBinary(
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
{
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
}
)
async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, {
headers: {
"Content-Type": "application/octet-stream",
Cookie: cookie,
},
responseType: "arraybuffer",
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateTitle(
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
name: string;
},
cookie: string,
abortSignal?: AbortSignal
): Promise<any> {
// Early abort check
if (abortSignal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
// Create an abort listener that will reject the pending promise
let abortListener: (() => void) | undefined;
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
}
});
try {
// The actual API call that can be aborted
return await Promise.race([
this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, data, {
headers: {
Cookie: cookie,
},
signal: abortSignal,
})
.then((response) => response?.data)
.catch((error) => {
// Special handling for aborted fetch requests
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
}
throw error;
}),
abortPromise,
]);
} finally {
// Clean up abort listener
if (abortSignal && abortListener) {
abortSignal.removeEventListener("abort", abortListener);
}
}
}
async updateDescription(
workspaceSlug: string,
projectId: string,
@@ -58,18 +95,15 @@ export class PageService extends APIService {
description_binary: string;
description_html: string;
description: object;
name: string;
},
cookie: string
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
{
headers: {
Cookie: cookie,
},
}
)
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, {
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error;
+1 -1
View File
@@ -1,7 +1,7 @@
// types
import type { IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
import { API_BASE_URL, APIService } from "@/core/services/api.service";
export class UserService extends APIService {
constructor() {
+167
View File
@@ -0,0 +1,167 @@
import { Server as HttpServer } from "http";
import { Hocuspocus } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { RedisManager } from "@/core/lib/redis-manager";
// config
import { serverConfig } from "@/config/server-config";
import { Server } from "http";
// Global flag to prevent duplicate shutdown sequences
let isGlobalShutdownInProgress = false;
/**
* ShutdownManager - Handles graceful shutdown of all server components
*
* Implements the singleton pattern to ensure only one shutdown sequence
* can be initiated throughout the application.
*/
export class ShutdownManager {
private static instance: ShutdownManager;
private httpServer: HttpServer | null = null;
private hocuspocusServer: Hocuspocus | null = null;
private isShuttingDown = false;
private exitCode = 0;
private forceExitTimeout: NodeJS.Timeout | null = null;
// Private constructor to enforce singleton pattern
private constructor() {}
/**
* Get the singleton instance
*/
public static getInstance(): ShutdownManager {
if (!ShutdownManager.instance) {
ShutdownManager.instance = new ShutdownManager();
}
return ShutdownManager.instance;
}
/**
* Register server instances that need to be gracefully closed during shutdown
*/
public register(httpServer: Server, hocuspocusServer: Hocuspocus): void {
this.httpServer = httpServer;
this.hocuspocusServer = hocuspocusServer;
logger.info("ShutdownManager registered with server instances");
}
/**
* Check if a shutdown is in progress
*/
public isShutdownInProgress(): boolean {
return this.isShuttingDown || isGlobalShutdownInProgress;
}
/**
* Initiate graceful shutdown sequence
* @param reason Reason for shutdown
* @param exitCode Process exit code (default: 0)
*/
public async shutdown(reason: string, exitCode = 0): Promise<void> {
// Prevent multiple shutdown attempts
if (this.isShuttingDown || isGlobalShutdownInProgress) {
logger.warn("Shutdown already in progress, ignoring additional shutdown request");
return;
}
this.isShuttingDown = true;
isGlobalShutdownInProgress = true;
this.exitCode = exitCode;
logger.info(`Initiating graceful shutdown: ${reason}`);
// Create a timeout to force exit if shutdown takes too long
this.forceExitTimeout = setTimeout(() => {
logger.error("Forcing termination after timeout - some connections may not have closed gracefully.");
process.exit(1);
}, serverConfig.terminationTimeout || 10000); // Default to 10 seconds if not configured
try {
// Close components in order: Redis, HocusPocus, HTTP server
await this.closeRedisConnections();
await this.closeHocusPocusServer();
await this.closeHttpServer();
// Wait a bit to allow handles to close
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("All components shut down successfully");
} catch (error) {
console.error("Error during graceful shutdown:", error);
} finally {
// Clear timeout if we've made it this far
if (this.forceExitTimeout) {
clearTimeout(this.forceExitTimeout);
}
// Give a small delay before exiting to ensure all handles are closed
setTimeout(() => {
console.info(`Exiting process with code ${this.exitCode}`);
process.exit(this.exitCode);
}, 100);
}
}
/**
* Close Redis connections
*/
private async closeRedisConnections(): Promise<void> {
console.info("Closing Redis connections...");
try {
const redisManager = RedisManager.getInstance();
const redisClient = redisManager.getClient();
if (redisClient) {
await redisClient.quit();
console.info("Redis connections closed successfully");
} else {
console.info("No Redis connections to close");
}
} catch (error) {
console.error("Error closing Redis connections:", error);
}
}
/**
* Close HocusPocus server
*/
private async closeHocusPocusServer(): Promise<void> {
console.info("Shutting down HocusPocus server...");
try {
if (this.hocuspocusServer) {
await this.hocuspocusServer.destroy();
console.info("HocusPocus server shut down successfully");
} else {
console.info("No HocusPocus server to shut down");
}
} catch (error) {
console.error("Error shutting down HocusPocus server:", error);
}
}
/**
* Close HTTP server
*/
private async closeHttpServer(): Promise<void> {
logger.info("Closing HTTP server...");
return new Promise<void>((resolve) => {
if (!this.httpServer) {
console.info("No HTTP server to close");
resolve();
return;
}
// Close all connections
this.httpServer.closeAllConnections?.();
this.httpServer.close((error) => {
if (error) {
console.error("Error closing HTTP server:", error);
} else {
console.info("HTTP server closed successfully");
}
resolve();
});
});
}
}
+7 -1
View File
@@ -1,10 +1,16 @@
// types
import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js";
import { TAdditionalDocumentTypes } from "@/plane-live/types/common";
export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
projectId: string;
workspaceSlug: string;
documentType: TDocumentTypes;
userId: string;
agentId: string;
parentId: string;
};
export type TConvertDocumentRequestBody = {
+68
View File
@@ -0,0 +1,68 @@
import { HocusPocusServerContext } from "@/core/types/common";
/**
* Parameters for document fetch operations
*/
export interface DocumentFetchParams {
context: HocusPocusServerContext;
pageId: string;
}
/**
* Parameters for document store operations
*/
export interface DocumentStoreParams {
context: HocusPocusServerContext;
pageId: string;
state: Uint8Array;
title: string;
}
/**
* Interface defining a document handler
*/
export interface DocumentHandler {
/**
* Fetch a document
*/
fetch: (params: DocumentFetchParams) => Promise<any>;
/**
* Store a document
*/
store: (params: DocumentStoreParams) => Promise<void>;
/**
* Fetch title
*/
fetchTitle: (params: { pageId: string; context: HocusPocusServerContext }) => Promise<string | undefined>;
/**
* Update title
*/
updateTitle?: (params: {
context: HocusPocusServerContext;
pageId: string;
title: string;
abortSignal?: AbortSignal;
}) => Promise<void>;
}
/**
* Handler selector function type - determines if a handler should be used based on context
*/
export type HandlerSelector = (context: HocusPocusServerContext) => 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
View File
@@ -0,0 +1 @@
export * from "./register";
+5
View File
@@ -0,0 +1,5 @@
import { registerProjectPageHandler } from "@/ce/document-types/project-page";
export function initializeDocumentHandlers() {
registerProjectPageHandler();
}
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/authentication.js"
export * from "../../core/lib/authentication";
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/fetch-document.js"
export * from "../../ce/lib/fetch-document";
+2 -1
View File
@@ -1 +1,2 @@
export * from "../../ce/lib/update-document.js"
export * from "../../ce/lib/update-document";
+1 -1
View File
@@ -1 +1 @@
export * from "../../ce/types/common.js"
export * from "../../ce/types/common"
+42
View File
@@ -0,0 +1,42 @@
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("*"),
// 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 termination 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();
+32
View File
@@ -0,0 +1,32 @@
import { Router } from "express";
import { registerControllers as registerRestControllers, registerWebSocketControllers } from "@plane/decorators";
import "reflect-metadata";
/**
* Register all controllers from the controllers array
* @param router Express router to register routes on
* @param controllers Array of controller classes to register
* @param dependencies Array of dependencies to pass to controllers
*/
export function registerControllers(router: Router, controllers: any[], dependencies: any[] = []): void {
controllers.forEach((Controller) => {
// Create the controller instance with dependencies
const instance = new Controller(...dependencies);
// Determine if it's a WebSocket controller or REST controller by checking
// if it has any methods with the "ws" method metadata
const isWebsocket = Object.getOwnPropertyNames(Controller.prototype).some((methodName) => {
if (methodName === "constructor") return false;
return Reflect.getMetadata("method", instance, methodName) === "ws";
});
if (isWebsocket) {
// Register as WebSocket controller
// Pass the existing instance with dependencies to avoid creating a new instance without them
registerWebSocketControllers(router, Controller, instance);
} else {
// Register as REST controller - doesn't accept an instance parameter
registerRestControllers(router, Controller);
}
});
}
+21
View File
@@ -0,0 +1,21 @@
import "reflect-metadata";
import { asyncHandler } from "@/core/helpers/error-handling/error-handler";
/**
* 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;
};
};
+141 -115
View File
@@ -1,135 +1,161 @@
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
import type { Application, Request, Router } from "express";
import expressWs from "express-ws";
import type * as ws from "ws";
import type { Hocuspocus } from "@hocuspocus/server";
const app: any = express();
expressWs(app);
// Environment and configuration
import { serverConfig, configureServerMiddleware } from "./config/server-config";
app.set("port", process.env.PORT || 3000);
// Core functionality
import { getHocusPocusServer } from "@/core/hocuspocus-server";
import { ProcessManager } from "@/core/process-manager";
import { ShutdownManager } from "@/core/shutdown-manager";
// Security middleware
app.use(helmet());
import { registerControllers } from "./lib/controller.utils";
// Middleware for response compression
app.use(
compression({
level: 6,
threshold: 5 * 1000,
})
);
// Redis manager
import { RedisManager } from "@/core/lib/redis-manager";
// Logging middleware
app.use(logger);
// Logging
import { logger } from "@plane/logger";
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Error handling
import { configureErrorHandlers } from "@/core/helpers/error-handling/error-handler";
import { handleError } from "@/core/helpers/error-handling/error-factory";
import { getAllControllers } from "./core/controller-registry";
// cors middleware
app.use(cors());
// WebSocket router type definition
interface WebSocketRouter extends Router {
ws: (_path: string, _handler: (ws: ws.WebSocket, req: Request) => void) => void;
}
const router = express.Router();
/**
* Main server class for the application
*/
export class Server {
private readonly app: Application;
private readonly port: number;
private hocusPocusServer!: Hocuspocus;
private redisManager: RedisManager;
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
/**
* Creates an instance of the server class.
* @param port Optional port number, defaults to environment configuration
*/
constructor(port?: number) {
this.app = express();
this.port = port || serverConfig.port;
this.redisManager = RedisManager.getInstance();
router.get("/health", (_req, res) => {
res.status(200).json({ status: "OK" });
});
// Initialize express-ws after Express setup
expressWs(this.app as any);
router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
ws.close();
configureServerMiddleware(this.app);
}
});
router.post("/convert-document", (req, res) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
/**
* Get the Express application instance
* Useful for testing
*/
getApp(): Application {
return this.app;
}
/**
* Initialize the server with all required components
* @returns The server instance for chaining
*/
async initialize() {
try {
// Initialize core services
await this.initializeServices();
// Set up routes
await this.setupRoutes();
// Set up error handlers
logger.info("Setting up error handlers");
configureErrorHandlers(this.app);
return this;
} catch (error) {
logger.error("Failed to initialize server:", error);
// This will always throw (never returns) - TypeScript correctly infers this
handleError(error, {
errorType: "internal",
component: "server",
operation: "initialize",
throw: true,
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
res.status(404).send("Not Found");
});
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
/**
* Initialize core services
*/
private async initializeServices() {
logger.info("Initializing Redis connection...");
await this.redisManager.connect();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Initialize the Hocuspocus server
this.hocusPocusServer = await getHocusPocusServer();
}
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
/**
* Set up API routes and WebSocket endpoints
*/
private async setupRoutes() {
try {
const router = express.Router() as WebSocketRouter;
// Get all controller classes
const controllers = getAllControllers();
// Register controllers with our simplified approach
// Pass the hocuspocus server as a dependency to the controllers that need it
registerControllers(router, controllers, [this.hocusPocusServer]);
// 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}`);
});
// Register servers with ShutdownManager
const shutdownManager = ShutdownManager.getInstance();
shutdownManager.register(server, this.hocusPocusServer);
// Setup graceful termination via ProcessManager (for signal handling)
const processManager = new ProcessManager(this.hocusPocusServer, server);
processManager.registerTerminationHandlers();
return server;
} catch (error) {
handleError(error, {
errorType: "service-unavailable",
component: "server",
operation: "start",
extraContext: { port: this.port },
throw: true,
});
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Server } from "./server";
import { env } from "./env";
import { logger } from "@plane/logger";
import { handleError } from "@/core/helpers/error-handling/error-factory";
/**
* 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);
// Create an AppError but DON'T exit
handleError(error, {
errorType: "internal",
component: "startup",
operation: "startServer",
extraContext: { environment: env.NODE_ENV }
});
// Continue running even if startup had issues
logger.warn("Server encountered errors during startup but will continue running");
}
};
// Start the server
startServer();
+22 -7
View File
@@ -1,23 +1,38 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"module": "ES2015",
"moduleResolution": "Bundler",
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts",
"tsup.config.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+13 -9
View File
@@ -1,11 +1,15 @@
import { defineConfig, Options } from "tsup";
import { defineConfig } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
export default defineConfig({
entry: ["src/start.ts"],
format: ["esm", "cjs"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));
splitting: false,
sourcemap: true,
minify: false,
target: "node18",
outDir: "dist",
env: {
NODE_ENV: process.env.NODE_ENV || "development",
},
});
-15
View File
@@ -8,18 +8,3 @@ export const ISSUE_REACTION_EMOJI_CODES = [
"9992",
"128064",
];
export const RANDOM_EMOJI_CODES = [
"8986",
"9200",
"128204",
"127773",
"127891",
"128076",
"128077",
"128187",
"128188",
"128512",
"128522",
"128578",
];
+69 -75
View File
@@ -1,97 +1,91 @@
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
export enum EInboxIssueCurrentTab {
OPEN = "open",
CLOSED = "closed",
OPEN = "open",
CLOSED = "closed",
}
export enum EInboxIssueStatus {
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export enum EInboxIssueSource {
IN_APP = "IN_APP",
FORMS = "FORMS",
EMAIL = "EMAIL",
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus;
export type TInboxIssue = {
id: string;
status: TInboxIssueStatus;
snoozed_till: Date | null;
duplicate_to: string | undefined;
source: EInboxIssueSource | undefined;
issue: TIssue;
created_by: string;
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
id: string;
status: TInboxIssueStatus;
snoozed_till: Date | null;
duplicate_to: string | undefined;
source: string;
issue: TIssue;
created_by: string;
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
};
export const INBOX_STATUS: {
key: string;
status: TInboxIssueStatus;
i18n_title: string;
i18n_description: () => string;
key: string;
status: TInboxIssueStatus;
i18n_title: string;
i18n_description: () => string;
}[] = [
{
key: "pending",
i18n_title: "inbox_issue.status.pending.title",
status: EInboxIssueStatus.PENDING,
i18n_description: () => `inbox_issue.status.pending.description`,
},
{
key: "declined",
i18n_title: "inbox_issue.status.declined.title",
status: EInboxIssueStatus.DECLINED,
i18n_description: () => `inbox_issue.status.declined.description`,
},
{
key: "snoozed",
i18n_title: "inbox_issue.status.snoozed.title",
status: EInboxIssueStatus.SNOOZED,
i18n_description: () => `inbox_issue.status.snoozed.description`,
},
{
key: "accepted",
i18n_title: "inbox_issue.status.accepted.title",
status: EInboxIssueStatus.ACCEPTED,
i18n_description: () => `inbox_issue.status.accepted.description`,
},
{
key: "duplicate",
i18n_title: "inbox_issue.status.duplicate.title",
status: EInboxIssueStatus.DUPLICATE,
i18n_description: () => `inbox_issue.status.duplicate.description`,
},
{
key: "pending",
i18n_title: "inbox_issue.status.pending.title",
status: EInboxIssueStatus.PENDING,
i18n_description: () => `inbox_issue.status.pending.description`,
},
{
key: "declined",
i18n_title: "inbox_issue.status.declined.title",
status: EInboxIssueStatus.DECLINED,
i18n_description: () => `inbox_issue.status.declined.description`,
},
{
key: "snoozed",
i18n_title: "inbox_issue.status.snoozed.title",
status: EInboxIssueStatus.SNOOZED,
i18n_description: () => `inbox_issue.status.snoozed.description`,
},
{
key: "accepted",
i18n_title: "inbox_issue.status.accepted.title",
status: EInboxIssueStatus.ACCEPTED,
i18n_description: () => `inbox_issue.status.accepted.description`,
},
{
key: "duplicate",
i18n_title: "inbox_issue.status.duplicate.title",
status: EInboxIssueStatus.DUPLICATE,
i18n_description: () => `inbox_issue.status.duplicate.description`,
},
];
export const INBOX_ISSUE_ORDER_BY_OPTIONS = [
{
key: "issue__created_at",
i18n_label: "inbox_issue.order_by.created_at",
},
{
key: "issue__updated_at",
i18n_label: "inbox_issue.order_by.updated_at",
},
{
key: "issue__sequence_id",
i18n_label: "inbox_issue.order_by.id",
},
{
key: "issue__created_at",
i18n_label: "inbox_issue.order_by.created_at",
},
{
key: "issue__updated_at",
i18n_label: "inbox_issue.order_by.updated_at",
},
{
key: "issue__sequence_id",
i18n_label: "inbox_issue.order_by.id",
},
];
export const INBOX_ISSUE_SORT_BY_OPTIONS = [
{
key: "asc",
i18n_label: "common.sort.asc",
},
{
key: "desc",
i18n_label: "common.sort.desc",
},
{
key: "asc",
i18n_label: "common.sort.asc",
},
{
key: "desc",
i18n_label: "common.sort.desc",
},
];
-3
View File
@@ -14,7 +14,6 @@ export * from "./state";
export * from "./swr";
export * from "./tab-indices";
export * from "./user";
export * from "./payment";
export * from "./workspace";
export * from "./stickies";
export * from "./cycle";
@@ -30,5 +29,3 @@ export * from "./event-tracker";
export * from "./spreadsheet";
export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
-1
View File
@@ -41,7 +41,6 @@ export enum EIssueGroupBYServerToProperty {
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
WORK_ITEMS = "work-items",
}
export enum EIssuesStoreType {
-163
View File
@@ -1,163 +0,0 @@
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
/**
* Enum representing different product subscription types
*/
export enum EProductSubscriptionEnum {
FREE = "FREE",
ONE = "ONE",
PRO = "PRO",
BUSINESS = "BUSINESS",
ENTERPRISE = "ENTERPRISE",
}
/**
* Default billing frequency for each product subscription type
*/
export const DEFAULT_PRODUCT_BILLING_FREQUENCY: TProductBillingFrequency = {
[EProductSubscriptionEnum.FREE]: undefined,
[EProductSubscriptionEnum.ONE]: undefined,
[EProductSubscriptionEnum.PRO]: "month",
[EProductSubscriptionEnum.BUSINESS]: "month",
[EProductSubscriptionEnum.ENTERPRISE]: "month",
};
/**
* Subscription types that support billing frequency toggle (monthly/yearly)
*/
export const SUBSCRIPTION_WITH_BILLING_FREQUENCY = [
EProductSubscriptionEnum.PRO,
EProductSubscriptionEnum.BUSINESS,
EProductSubscriptionEnum.ENTERPRISE,
];
/**
* Mapping of product subscription types to their respective payment product details
* Used to provide information about each product's pricing and features
*/
export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
[EProductSubscriptionEnum.PRO]: {
id: EProductSubscriptionEnum.PRO,
name: "Plane Pro",
description:
"More views, more cycles powers, more pages features, new reports, and better dashboards are waiting to be unlocked.",
type: "PRO",
prices: [
{
id: `price_monthly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 800,
recurring: "month",
currency: "usd",
workspace_amount: 800,
product: EProductSubscriptionEnum.PRO,
},
{
id: `price_yearly_${EProductSubscriptionEnum.PRO}`,
unit_amount: 7200,
recurring: "year",
currency: "usd",
workspace_amount: 7200,
product: EProductSubscriptionEnum.PRO,
},
],
payment_quantity: 1,
is_active: true,
},
[EProductSubscriptionEnum.BUSINESS]: {
id: EProductSubscriptionEnum.BUSINESS,
name: "Plane Business",
description:
"The earliest packaging of Business at $10 a seat a month billed annually, $12 a seat a month billed monthly for Plane Cloud",
type: "BUSINESS",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: false,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
name: "Plane Enterprise",
description: "",
type: "ENTERPRISE",
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
{
id: `price_monthly_${EProductSubscriptionEnum.ENTERPRISE}`,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 0,
product: EProductSubscriptionEnum.ENTERPRISE,
},
],
payment_quantity: 1,
is_active: false,
},
};
/**
* URL for the "Talk to Sales" page where users can contact sales team
*/
export const TALK_TO_SALES_URL = "https://plane.so/talk-to-sales";
/**
* Mapping of subscription types to their respective upgrade/redirection URLs based on billing frequency
* Used for self-hosted installations to redirect users to appropriate upgrade pages
*/
export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Record<TBillingFrequency, string>> = {
[EProductSubscriptionEnum.FREE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ONE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.PRO]: {
month: "https://app.plane.so/upgrade/pro/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
};
/**
* Mapping of subscription types to their respective marketing webpage URLs
* Used to direct users to learn more about each plan's features and pricing
*/
export const SUBSCRIPTION_WEBPAGE_URLS: Record<EProductSubscriptionEnum, string> = {
[EProductSubscriptionEnum.FREE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.ONE]: TALK_TO_SALES_URL,
[EProductSubscriptionEnum.PRO]: "https://plane.so/pro",
[EProductSubscriptionEnum.BUSINESS]: "https://plane.so/business",
[EProductSubscriptionEnum.ENTERPRISE]: "https://plane.so/business",
};
+2 -19
View File
@@ -1,7 +1,5 @@
// plane imports
import { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// local imports
import { RANDOM_EMOJI_CODES } from "./emoji";
// icons
import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -134,18 +132,3 @@ export const PROJECT_ERROR_MESSAGES = {
i18n_message: "workspace_projects.error.issue_delete",
},
};
export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
};
+10 -8
View File
@@ -1,4 +1,9 @@
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TStateGroups =
| "backlog"
| "unstarted"
| "started"
| "completed"
| "cancelled";
export type TDraggableData = {
groupKey: TStateGroups;
@@ -9,43 +14,40 @@ export const STATE_GROUPS: {
[key in TStateGroups]: {
key: TStateGroups;
label: string;
defaultStateName: string;
color: string;
};
} = {
backlog: {
key: "backlog",
label: "Backlog",
defaultStateName: "Backlog",
color: "#d9d9d9",
},
unstarted: {
key: "unstarted",
label: "Unstarted",
defaultStateName: "Todo",
color: "#3f76ff",
},
started: {
key: "started",
label: "Started",
defaultStateName: "In Progress",
color: "#f59e0b",
},
completed: {
key: "completed",
label: "Completed",
defaultStateName: "Done",
color: "#16a34a",
},
cancelled: {
key: "cancelled",
label: "Canceled",
defaultStateName: "Cancelled",
color: "#dc2626",
},
};
export const ARCHIVABLE_STATE_GROUPS = [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key];
export const ARCHIVABLE_STATE_GROUPS = [
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const COMPLETED_STATE_GROUPS = [STATE_GROUPS.completed.key];
export const PENDING_STATE_GROUPS = [
STATE_GROUPS.backlog.key,
-42
View File
@@ -1,42 +0,0 @@
export const ENTERPRISE_PLAN_FEATURES = [
"Private + managed deployments",
"GAC",
"LDAP support",
"Databases + Formulas",
"Unlimited and full Automation Flows",
"Full-suite professional services",
];
export const BUSINESS_PLAN_FEATURES = [
"Project Templates",
"Workflows + Approvals",
"Decision + Loops Automation",
"Custom Reports",
"Nested Pages",
"Intake Forms",
];
export const PRO_PLAN_FEATURES = [
"Dashboards + Reports",
"Full Time Tracking + Bulk Ops",
"Teamspaces",
"Trigger And Action",
"Wikis",
"Popular integrations",
];
export const ONE_PLAN_FEATURES = [
"OIDC + SAML for SSO",
"Active Cycles",
"Real-time collab + public views and page",
"Link pages in issues and vice-versa",
"Time-tracking + limited bulk ops",
"Docker, Kubernetes and more",
];
export const FREE_PLAN_UPGRADE_FEATURES = [
"OIDC + SAML for SSO",
"Time Tracking and Bulk Ops",
"Integrations",
"Public Views and Pages",
];
-1
View File
@@ -12,4 +12,3 @@ import * as WebSocketDecorators from "./websocket";
// Named namespace exports
export const Rest = RestDecorators;
export const WebSocketNS = WebSocketDecorators;
+3
View File
@@ -43,6 +43,9 @@
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-character-count": "2.11.0",
"@tiptap/extension-collaboration": "2.11.0",
"@tiptap/extension-text": "2.11.0",
"@tiptap/extension-document": "2.11.0",
"@tiptap/extension-heading": "2.11.0",
"@tiptap/extension-image": "2.11.0",
"@tiptap/extension-list-item": "2.11.0",
"@tiptap/extension-mention": "2.11.0",

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