Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0da90f184 | ||
|
|
84d403fb40 | ||
|
|
8becfffed2 | ||
|
|
bdb14d519d | ||
|
|
d55b92ab1c | ||
|
|
f168bd3beb | ||
|
|
a74fdf235b | ||
|
|
9f441ef136 | ||
|
|
ad9888ce45 | ||
|
|
16bd86dc0c | ||
|
|
4eef115fcd | ||
|
|
cabd5f4275 | ||
|
|
c5c3344ba0 | ||
|
|
7393f2ed7f | ||
|
|
bc4398e344 | ||
|
|
7cbdcd4c94 | ||
|
|
cef60b55e4 | ||
|
|
07bf68c8ca | ||
|
|
3d07d0f678 | ||
|
|
6c2fb4b287 | ||
|
|
c1b8feaf6f | ||
|
|
8a9cdc6133 | ||
|
|
6db75fe29c | ||
|
|
ea7ebe66b1 | ||
|
|
93066ef5d5 | ||
|
|
2db9d35678 | ||
|
|
37cd01e306 | ||
|
|
d3defc9785 | ||
|
|
6d087168e2 | ||
|
|
8ba72e7c3d | ||
|
|
3a0891e0ee | ||
|
|
b035e63d38 | ||
|
|
251149bbfa | ||
|
|
53efad3399 | ||
|
|
304ef1a80c | ||
|
|
16d41a3841 | ||
|
|
c2b53cc38e | ||
|
|
629d1943f3 | ||
|
|
a9f4427b21 | ||
|
|
22905ca662 | ||
|
|
d9df474b66 | ||
|
|
e4f31aea08 | ||
|
|
c2a3e47d3d | ||
|
|
cef4110eb0 | ||
|
|
3672ee4ef1 | ||
|
|
c56097b8c0 | ||
|
|
0d57e0ab32 | ||
|
|
df35ccecc9 | ||
|
|
38d8d3ea9b | ||
|
|
3710b182d3 | ||
|
|
6897575a62 | ||
|
|
388151b70b | ||
|
|
3d61604569 | ||
|
|
1b29f65664 | ||
|
|
a229508611 | ||
|
|
d5bd4ef63a | ||
|
|
146332fff3 | ||
|
|
5802858772 | ||
|
|
b39ce9c18a | ||
|
|
a7ab5ae680 | ||
|
|
f2a08853e2 | ||
|
|
23eeb45713 | ||
|
|
6c83a0df09 | ||
|
|
dbee7488e1 |
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
-31
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -59,7 +59,6 @@ MIDDLEWARE = [
|
||||
"crum.CurrentRequestUserMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
|
||||
"plane.middleware.logger.RequestLoggerMiddleware",
|
||||
]
|
||||
|
||||
# Rest Framework settings
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { registerProjectPageHandler } from "./project-page";
|
||||
|
||||
export function initializeDocumentHandlers() {
|
||||
registerProjectPageHandler();
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
import { AppError, catchAsync } from "@/core/helpers/error-handling/error-handler";
|
||||
import { TDocumentTypes } from "@/core/types/common";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
documentType: TDocumentTypes | undefined;
|
||||
interface TArgs {
|
||||
cookie: string;
|
||||
documentType: TDocumentTypes;
|
||||
pageId: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
export const fetchDocument = async (args: TArgs): Promise<Uint8Array | null> => {
|
||||
const { documentType } = args;
|
||||
throw Error(`Fetch failed: Invalid document type ${documentType} provided.`);
|
||||
}
|
||||
const { cookie, documentType, pageId, params } = args;
|
||||
|
||||
if (!documentType) {
|
||||
throw new AppError("Document type is required");
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
throw new AppError("Page ID is required");
|
||||
}
|
||||
|
||||
return null
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// types
|
||||
import { TDocumentTypes } from "@/core/types/common.js";
|
||||
import { TDocumentTypes } from "@/core/types/common";
|
||||
|
||||
type TArgs = {
|
||||
cookie: string | undefined;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export type TAdditionalDocumentTypes = {};
|
||||
export type TAdditionalDocumentTypes = string;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { env } from "@/env";
|
||||
import compression from "compression";
|
||||
import helmet from "helmet";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import express from "express";
|
||||
import { logger } from "@plane/logger";
|
||||
import { logger as loggerMiddleware } from "@/core/helpers/logger";
|
||||
|
||||
/**
|
||||
* Configure server middleware
|
||||
* @param app Express application
|
||||
*/
|
||||
export function configureServerMiddleware(app: express.Application): void {
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// CORS configuration
|
||||
configureCors(app);
|
||||
|
||||
// Compression middleware
|
||||
app.use(
|
||||
compression({
|
||||
level: env.COMPRESSION_LEVEL,
|
||||
threshold: env.COMPRESSION_THRESHOLD,
|
||||
}) as unknown as express.RequestHandler
|
||||
);
|
||||
|
||||
// Cookie parsing
|
||||
app.use(cookieParser());
|
||||
|
||||
// Logging middleware
|
||||
app.use(loggerMiddleware);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure CORS
|
||||
* @param app Express application
|
||||
*/
|
||||
function configureCors(app: express.Application): void {
|
||||
const origins = env.CORS_ALLOWED_ORIGINS?.split(",").map((origin) => origin.trim()) || [];
|
||||
for (const origin of origins) {
|
||||
logger.info(`Adding CORS allowed origin: ${origin}`);
|
||||
app.use(
|
||||
cors({
|
||||
origin,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-api-key"],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server configuration
|
||||
*/
|
||||
export const serverConfig = {
|
||||
port: env.PORT,
|
||||
basePath: env.LIVE_BASE_PATH,
|
||||
terminationTimeout: env.SHUTDOWN_TIMEOUT,
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" },
|
||||
}
|
||||
)();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import { handleError } from "./error-handling/error-factory";
|
||||
|
||||
/**
|
||||
* A simple validation utility that integrates with our error system.
|
||||
*
|
||||
* This provides a fluent interface for validating data and throwing
|
||||
* appropriate errors if validation fails.
|
||||
*/
|
||||
export class Validator<T> {
|
||||
constructor(
|
||||
private readonly data: T,
|
||||
private readonly name: string = "data"
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ensures a value is defined (not undefined or null)
|
||||
*/
|
||||
required(message?: string): Validator<T> {
|
||||
if (this.data === undefined || this.data === null) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} is required`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateRequired',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a value is a string
|
||||
*/
|
||||
string(message?: string): Validator<T> {
|
||||
if (typeof this.data !== "string") {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} must be a string`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateString',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a string is not empty
|
||||
*/
|
||||
notEmpty(message?: string): Validator<T> {
|
||||
if (typeof this.data === "string" && this.data.trim() === "") {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} cannot be empty`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateNonEmptyString',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a value is a number
|
||||
*/
|
||||
number(message?: string): Validator<T> {
|
||||
if (typeof this.data !== "number" || isNaN(this.data)) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} must be a valid number`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateNumber',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an array is not empty
|
||||
*/
|
||||
nonEmptyArray(message?: string): Validator<T> {
|
||||
if (!Array.isArray(this.data) || this.data.length === 0) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} must be a non-empty array`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateArray',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a value matches a regular expression
|
||||
*/
|
||||
match(regex: RegExp, message?: string): Validator<T> {
|
||||
if (typeof this.data !== "string" || !regex.test(this.data)) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} has an invalid format`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateFormat',
|
||||
extraContext: { field: this.name, format: regex.toString() },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a value is one of the allowed values
|
||||
*/
|
||||
oneOf(allowedValues: any[], message?: string): Validator<T> {
|
||||
if (!allowedValues.includes(this.data)) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} must be one of: ${allowedValues.join(", ")}`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateEnum',
|
||||
extraContext: { field: this.name, allowedValues },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validation function
|
||||
*/
|
||||
custom(validationFn: (value: T) => boolean, message?: string): Validator<T> {
|
||||
if (!validationFn(this.data)) {
|
||||
throw handleError(new ValidationError(this.name, message || `${this.name} is invalid`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateCustom',
|
||||
extraContext: { field: this.name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validated data
|
||||
*/
|
||||
get(): T {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new validator for a value
|
||||
*/
|
||||
export const validate = <T>(data: T, name?: string): Validator<T> => {
|
||||
return new Validator(data, name);
|
||||
};
|
||||
|
||||
export default validate;
|
||||
|
||||
export class ValidationError extends Error {
|
||||
constructor(public name: string, message: string) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
export const validateRequired = (value: any, name: string, message?: string) => {
|
||||
if (value === undefined || value === null) {
|
||||
throw handleError(new ValidationError(name, message || `${name} is required`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateRequired',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateString = (value: any, name: string, message?: string) => {
|
||||
if (typeof value !== 'string') {
|
||||
throw handleError(new ValidationError(name, message || `${name} must be a string`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateString',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateNonEmptyString = (value: string, name: string, message?: string) => {
|
||||
if (!value.trim()) {
|
||||
throw handleError(new ValidationError(name, message || `${name} cannot be empty`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateNonEmptyString',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateNumber = (value: any, name: string, message?: string) => {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
throw handleError(new ValidationError(name, message || `${name} must be a valid number`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateNumber',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateArray = (value: any, name: string, message?: string) => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw handleError(new ValidationError(name, message || `${name} must be a non-empty array`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateArray',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateFormat = (value: string, name: string, format: RegExp, message?: string) => {
|
||||
if (!format.test(value)) {
|
||||
throw handleError(new ValidationError(name, message || `${name} has an invalid format`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateFormat',
|
||||
extraContext: { field: name, format: format.toString() },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateEnum = (value: any, name: string, allowedValues: any[], message?: string) => {
|
||||
if (!allowedValues.includes(value)) {
|
||||
throw handleError(new ValidationError(name, message || `${name} must be one of: ${allowedValues.join(", ")}`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateEnum',
|
||||
extraContext: { field: name, allowedValues },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateCustom = (value: any, name: string, validator: (value: any) => boolean, message?: string) => {
|
||||
if (!validator(value)) {
|
||||
throw handleError(new ValidationError(name, message || `${name} is invalid`), {
|
||||
errorType: 'bad-request',
|
||||
component: 'validation',
|
||||
operation: 'validateCustom',
|
||||
extraContext: { field: name },
|
||||
throw: true
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IncomingHttpHeaders } from "http";
|
||||
// lib
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
import { handleAuthentication } from "@/core/lib/authentication";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
import {
|
||||
DocumentCollaborativeEvents,
|
||||
TDocumentEventsServer,
|
||||
} from "@plane/editor/lib";
|
||||
import { getExtensions } from "@/core/extensions/index";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
// types
|
||||
import { type HocusPocusServerContext } from "@/core/types/common.js";
|
||||
import { TDocumentTypes, type HocusPocusServerContext } from "@/core/types/common";
|
||||
// error handling
|
||||
import { catchAsync } from "@/core/helpers/error-handling/error-handler";
|
||||
import { handleError } from "@/core/helpers/error-handling/error-factory";
|
||||
|
||||
export const getHocusPocusServer = async () => {
|
||||
const extensions = await getExtensions();
|
||||
@@ -21,53 +22,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
// types
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+7
-1
@@ -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
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./register";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { registerProjectPageHandler } from "@/ce/document-types/project-page";
|
||||
|
||||
export function initializeDocumentHandlers() {
|
||||
registerProjectPageHandler();
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/authentication.js"
|
||||
export * from "../../core/lib/authentication";
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/fetch-document.js"
|
||||
export * from "../../ce/lib/fetch-document";
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../../ce/lib/update-document.js"
|
||||
export * from "../../ce/lib/update-document";
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export * from "../../ce/types/common.js"
|
||||
export * from "../../ce/types/common"
|
||||
@@ -0,0 +1,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();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -41,7 +41,6 @@ export enum EIssueGroupBYServerToProperty {
|
||||
export enum EIssueServiceType {
|
||||
ISSUES = "issues",
|
||||
EPICS = "epics",
|
||||
WORK_ITEMS = "work-items",
|
||||
}
|
||||
|
||||
export enum EIssuesStoreType {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -12,4 +12,3 @@ import * as WebSocketDecorators from "./websocket";
|
||||
// Named namespace exports
|
||||
export const Rest = RestDecorators;
|
||||
export const WebSocketNS = WebSocketDecorators;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user