Compare commits

..

1 Commits

99 changed files with 2071 additions and 2879 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+423 -94
View File
@@ -12,7 +12,13 @@ from django.db.models import (
OuterRef,
Q,
Sum,
FloatField,
Case,
When,
Value,
)
from django.db.models.functions import Cast, Concat
from django.db import models
# Third party imports
from rest_framework import status
@@ -41,7 +47,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -195,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
data = CycleSerializer(
queryset,
many=True,
@@ -254,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
)
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
return self.paginate(
request=request,
queryset=(queryset),
@@ -302,17 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(
data=request.data, context={"request": request}
)
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -355,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{
"error": "Both start date and end date are either required or are to be null"
},
{"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -505,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
if cycle.archived_at:
return Response(
@@ -520,20 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
{"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request}
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -541,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", cycle.external_source
),
external_source=request.data.get("external_source", cycle.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -600,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
issue_activity.delay(
type="cycle.activity.deleted",
@@ -624,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Delete the cycle
cycle.delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle", entity_identifier=pk, project_id=project_id
).delete()
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -764,9 +742,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data,
)
@cycle_docs(
@@ -785,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
@@ -818,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -883,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -922,9 +892,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: IssueSerializer(
issues, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data,
)
@cycle_docs(
@@ -954,13 +922,10 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
if not issues:
return Response(
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"},
status=status.HTTP_400_BAD_REQUEST,
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST
)
cycle = Cycle.objects.get(
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id)
if cycle.end_date is not None and cycle.end_date < timezone.now():
return Response(
@@ -972,13 +937,9 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
)
# Get all CycleWorkItems already created
cycle_issues = list(
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)
)
cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues))
existing_issues = [
str(cycle_issue.issue_id)
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues
]
new_issues = list(set(issues) - set(existing_issues))
@@ -1029,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
"created_cycle_issues": serializers.serialize("json", created_records),
}
),
epoch=int(timezone.now().timestamp()),
@@ -1107,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
@@ -1214,34 +1171,406 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
old_cycle = Cycle.objects.get(
workspace__slug=slug,
project_id=project_id,
pk=cycle_id,
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
# transfer work items only when cycle is completed (passed the end data)
if old_cycle.end_date is not None and old_cycle.end_date < timezone.now():
return Response(
{"error": "The old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar", None),
"avatar_url": item.get("avatar_url", None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return Response(
{"error": result.get("error")},
{"error": "The cycle where the issues are transferred is already completed"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
-2
View File
@@ -17,7 +17,6 @@ from .views import urlpatterns as view_urls
from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls
urlpatterns = [
*analytic_urls,
@@ -39,5 +38,4 @@ urlpatterns = [
*api_urls,
*webhook_urls,
*timezone_urls,
*exporter_urls,
]
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path
from plane.app.views import ExportIssuesEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
+7
View File
@@ -7,6 +7,7 @@ from plane.app.views import (
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
@@ -140,6 +141,12 @@ urlpatterns = [
IssueAttachmentV2Endpoint.as_view(),
name="project-issue-attachments",
),
## Export Issues
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
+428 -111
View File
@@ -51,7 +51,6 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
@@ -97,9 +96,7 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar_asset", "first_name", "id"
).distinct(),
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
)
)
.prefetch_related(
@@ -150,8 +147,7 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -170,11 +166,7 @@ class CycleViewSet(BaseViewSet):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
& (
Q(
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
)
),
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -205,9 +197,7 @@ class CycleViewSet(BaseViewSet):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
data = queryset.values(
# necessary fields
@@ -274,16 +264,10 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -323,9 +307,7 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
# Send the model activity
model_activity.delay(
@@ -341,17 +323,13 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{
"error": "Both start date and end date are either required or are to be null"
},
{"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -359,29 +337,21 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
{"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -481,9 +451,7 @@ class CycleViewSet(BaseViewSet):
)
if data is None:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.first()
# Fetch the project timezone
@@ -505,11 +473,7 @@ class CycleViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
issue_activity.delay(
type="cycle.activity.deleted",
@@ -560,9 +524,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -635,23 +597,409 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.id,
new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first()
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar": item.get("avatar"),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Handle error response
if result.get("error"):
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return Response(
{"error": result["error"]},
{"error": "The cycle where the issues are transferred is already completed"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -666,12 +1014,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.filters)
cycle_properties.rich_filters = request.data.get(
"rich_filters", cycle_properties.rich_filters
)
cycle_properties.display_filters = request.data.get(
"display_filters", cycle_properties.display_filters
)
cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters)
cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters)
cycle_properties.display_properties = request.data.get(
"display_properties", cycle_properties.display_properties
)
@@ -695,13 +1039,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
if not cycle:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -747,9 +1087,7 @@ class CycleProgressEndpoint(BaseAPIView):
output_field=FloatField(),
)
),
total_estimate_points=Sum(
"value_as_float", default=Value(0), output_field=FloatField()
),
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
)
)
if cycle.progress_snapshot:
@@ -809,22 +1147,11 @@ class CycleProgressEndpoint(BaseAPIView):
return Response(
{
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
or 0,
"unstarted_estimate_points": aggregate_estimates[
"unstarted_estimate_point"
]
or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"]
or 0,
"cancelled_estimate_points": aggregate_estimates[
"cancelled_estimate_point"
]
or 0,
"completed_estimate_points": aggregate_estimates[
"completed_estimate_points"
]
or 0,
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -842,9 +1169,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -927,9 +1252,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -964,9 +1287,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1068,11 +1389,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
)
)
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"label_id",
+8 -17
View File
@@ -137,11 +137,7 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page_transaction.delay(request.data, None, serializer.data["id"])
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,8 +168,11 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
page_id=page_id,
)
@@ -505,11 +504,7 @@ class PagesDescriptionViewSet(BaseViewSet):
if serializer.is_valid():
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
# Update the page using serializer
updated_page = serializer.save()
@@ -555,11 +550,7 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
@@ -1154,7 +1154,10 @@ def create_comment_reaction_activity(
.values_list("id", "comment__id")
.first()
)
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
comment = IssueComment.objects.filter(pk=comment_id, project_id=project_id).first()
if comment is None:
return
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
+43 -105
View File
@@ -1,5 +1,5 @@
# Python imports
import logging
import json
# Django imports
from django.utils import timezone
@@ -7,134 +7,72 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# App imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
COMPONENT_MAP = {
"mention-component": {
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
"extract": lambda m: {
"entity_name": m.get("entity_name"),
"entity_type": None,
"entity_identifier": m.get("entity_identifier"),
},
},
"image-component": {
"attributes": ["id", "src"],
"extract": lambda m: {
"entity_name": "image",
"entity_type": None,
"entity_identifier": m.get("src"),
},
},
}
component_map = {
**COMPONENT_MAP,
}
def extract_all_components(description_html):
"""
Extracts all component types from the HTML value in a single pass.
Returns a dict mapping component_type -> list of extracted entities.
"""
def extract_components(value, tag):
try:
if not description_html:
return {component: [] for component in component_map.keys()}
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
soup = BeautifulSoup(description_html, "html.parser")
results = {}
for component, config in component_map.items():
attributes = config.get("attributes", ["id"])
component_tags = soup.find_all(component)
entities = []
for tag in component_tags:
entity = {attr: tag.get(attr) for attr in attributes}
entities.append(entity)
results[component] = entities
return results
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
return mentions
except Exception:
return {component: [] for component in component_map.keys()}
def get_entity_details(component: str, mention: dict):
"""
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
"""
config = component_map.get(component)
if not config:
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
return config["extract"](mention)
return []
@shared_task
def page_transaction(new_description_html, old_description_html, page_id):
"""
Tracks changes in page content (mentions, embeds, etc.)
and logs them in PageLog for audit and reference.
"""
def page_transaction(new_value, old_value, page_id):
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
old_value = json.loads(old_value) if old_value else {}
new_transactions = []
deleted_transaction_ids = set()
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
old_ids = {m.get("id") for m in old_entities if m.get("id")}
new_ids = {m.get("id") for m in new_entities if m.get("id")}
deleted_transaction_ids.update(old_ids - new_ids)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
for mention in new_entities:
mention_id = mention.get("id")
if not mention_id or (mention_id in old_ids and has_existing_logs):
continue
details = get_entity_details(component, mention)
current_time = timezone.now()
new_transactions.append(
PageLog(
transaction=mention_id,
page_id=page_id,
entity_identifier=details["entity_identifier"],
entity_name=details["entity_name"],
entity_type=details["entity_type"],
workspace_id=page.workspace_id,
created_at=current_time,
updated_at=current_time,
)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+2 -2
View File
@@ -300,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
+2 -7
View File
@@ -4,9 +4,7 @@ import nh3
from plane.utils.exception_logger import log_exception
from bs4 import BeautifulSoup
from collections import defaultdict
import logging
logger = logging.getLogger("plane.api")
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
@@ -56,9 +54,7 @@ def validate_binary_data(data):
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
@@ -236,9 +232,8 @@ def validate_html_content(html_content: str):
summary = json.dumps(diff)
except Exception:
summary = str(diff)
logger.warning(f"HTML sanitization removals: {summary}")
log_exception(
ValueError(f"HTML sanitization removals: {summary}"),
f"HTML sanitization removals: {summary}",
warning=True,
)
return True, None, clean_html
@@ -1,486 +0,0 @@
# Python imports
import json
# Django imports
from django.db.models import (
Case,
Count,
F,
Q,
Sum,
FloatField,
Value,
When,
)
from django.db import models
from django.db.models.functions import Cast, Concat
from django.utils import timezone
# Module imports
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
def transfer_cycle_issues(
slug,
project_id,
cycle_id,
new_cycle_id,
request,
user_id,
):
"""
Transfer incomplete issues from one cycle to another and create progress snapshot.
Args:
slug: Workspace slug
project_id: Project ID
cycle_id: Source cycle ID
new_cycle_id: Destination cycle ID
request: HTTP request object
user_id: User ID performing the transfer
Returns:
dict: Response data with success or error message
"""
# Get the new cycle
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return {
"success": False,
"error": "The cycle where the issues are transferred is already completed",
}
# Get the old cycle with issue counts
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
if old_cycle is None:
return {
"success": False,
"error": "Source cycle not found",
}
# Check if project uses estimates
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
# Initialize estimate distribution variables
assignee_estimate_distribution = []
label_estimate_distribution = []
estimate_completion_chart = {}
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee estimate distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label estimate distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serialization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Generate completion chart
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
# Get the current cycle and save progress snapshot
current_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=cycle_id
).first()
current_cycle.progress_snapshot = {
"total_issues": old_cycle.total_issues,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
# Get issues to transfer (only incomplete issues)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__archived_at__isnull=True,
issue__is_draft=False,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Bulk update cycle issues
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(user_id),
issue_id=None,
project_id=str(project_id),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": [],
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return {"success": True}
@@ -173,6 +173,7 @@ class IssueExportSchema(ExportSchema):
return self._format_date(last_cycle.cycle.start_date)
return ""
def prepare_cycle_end_date(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.js",
@@ -20,11 +20,11 @@
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
"@hocuspocus/server": "2.15.2",
"@hocuspocus/transformer": "2.15.2",
"@hocuspocus/extension-database": "3.2.5",
"@hocuspocus/extension-logger": "3.2.5",
"@hocuspocus/extension-redis": "3.2.5",
"@hocuspocus/server": "3.2.5",
"@hocuspocus/transformer": "3.2.5",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
+17 -60
View File
@@ -6,17 +6,22 @@ import {
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// services
// lib
import { getPageService } from "@/services/page/handler";
// type
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
import { broadcastError } from "@/utils/broadcast-error";
// force close utility
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
const normalizeToError = (error: unknown, fallbackMessage: string) => {
if (error instanceof Error) {
return error;
}
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
return new Error(message);
};
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
@@ -33,22 +38,12 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
// return binary data
return binaryData;
} catch (error) {
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in fetching document", appError);
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
throw appError;
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
}
};
const storeDocument = async ({
context,
state: pageBinaryData,
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
@@ -62,46 +57,8 @@ const storeDocument = async ({
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in updating document:", appError);
// Check error types
const isContentTooLarge = appError.statusCode === 413;
// Determine if we should disconnect and unload
const shouldDisconnect = isContentTooLarge;
// Determine error message and code
let errorMessage: string;
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
if (isContentTooLarge) {
errorMessage = "Document is too large to save. Please reduce the content size.";
errorCode = "content_too_large";
} else {
errorMessage = "Unable to save the page. Please try again.";
}
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
// If we should disconnect, close connections and unload document
if (shouldDisconnect) {
// Map error code to ForceCloseReason with proper types
const reason =
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
// force close connections and unload document
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
// Don't throw after force close - document is already unloaded
// Throwing would cause hocuspocus's finally block to access the null document
return;
}
throw appError;
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
throw normalizeToError(error, `Failed to update document: ${pageId}`);
}
};
@@ -1,203 +0,0 @@
import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { Redis } from "@/extensions/redis";
import {
AdminCommand,
CloseCode,
ForceCloseReason,
getForceCloseMessage,
isForceCloseCommand,
type ClientForceCloseMessage,
type ForceCloseCommandData,
} from "@/types/admin-commands";
/**
* Extension to handle force close commands from other servers via Redis admin channel
*/
export class ForceCloseHandler implements Extension {
name = "ForceCloseHandler";
priority = 999;
async onConfigure({ instance }: onConfigurePayload) {
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (!redisExt) {
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
return;
}
// Register handler for force_close admin command
redisExt.onAdminCommand<ForceCloseCommandData>(AdminCommand.FORCE_CLOSE, async (data) => {
// Type guard for safety
if (!isForceCloseCommand(data)) {
logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command");
return;
}
const { docId, reason, code } = data;
const document = instance.documents.get(docId);
if (!document) {
// Not our document, ignore
return;
}
const connectionCount = document.getConnectionsCount();
logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`);
// Step 1: Send force close message to ALL clients first
const forceCloseMessage: ClientForceCloseMessage = {
type: "force_close",
reason,
code,
message: getForceCloseMessage(reason),
timestamp: new Date().toISOString(),
};
let messageSent = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.sendStateless(JSON.stringify(forceCloseMessage));
messageSent++;
} catch (error) {
logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error);
}
});
logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`);
// Wait a moment for messages to be delivered
await new Promise((resolve) => setTimeout(resolve, 50));
// Step 2: Close connections
logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`);
let closed = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.close({ code, reason });
closed++;
} catch (error) {
logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error);
}
});
logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`);
});
logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension");
}
}
/**
* Force close all connections to a document across all servers and unload it from memory.
* Used for critical errors or admin operations.
*
* @param instance - The Hocuspocus server instance
* @param pageId - The document ID to force close
* @param reason - The reason for force closing
* @param code - Optional WebSocket close code (defaults to FORCE_CLOSE)
* @returns Promise that resolves when document is closed and unloaded
* @throws Error if document not found in memory
*/
export const forceCloseDocumentAcrossServers = async (
instance: Hocuspocus,
pageId: string,
reason: ForceCloseReason,
code: CloseCode = CloseCode.FORCE_CLOSE
): Promise<void> => {
// STEP 1: VERIFY DOCUMENT EXISTS
const document = instance.documents.get(pageId);
if (!document) {
logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`);
return; // Document already cleaned up, nothing to do
}
const connectionsBefore = document.getConnectionsCount();
logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`);
const forceCloseMessage: ClientForceCloseMessage = {
type: "force_close",
reason,
code,
message: getForceCloseMessage(reason),
timestamp: new Date().toISOString(),
};
let messageSentCount = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.sendStateless(JSON.stringify(forceCloseMessage));
messageSentCount++;
} catch (error) {
logger.error("[FORCE_CLOSE] Failed to send message to client:", error);
}
});
logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`);
// Wait a moment for messages to be delivered
await new Promise((resolve) => setTimeout(resolve, 50));
// STEP 3: CLOSE LOCAL CONNECTIONS
logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`);
let closedCount = 0;
document.connections.forEach(({ connection }: { connection: Connection }) => {
try {
connection.close({ code, reason });
closedCount++;
} catch (error) {
logger.error("[FORCE_CLOSE] Failed to close local connection:", error);
}
});
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
// STEP 4: BROADCAST TO OTHER SERVERS
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (redisExt) {
const commandData: ForceCloseCommandData = {
command: AdminCommand.FORCE_CLOSE,
docId: pageId,
reason,
code,
originServer: instance.configuration.name || "unknown",
timestamp: new Date().toISOString(),
};
const receivers = await redisExt.publishAdminCommand(commandData);
logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`);
} else {
logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers");
}
// STEP 5: WAIT FOR OTHER SERVERS
const waitTime = 800;
logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`);
await new Promise((resolve) => setTimeout(resolve, waitTime));
// STEP 6: UNLOAD DOCUMENT after closing all the connections
logger.info(`[FORCE_CLOSE] Unloading document from memory...`);
try {
await instance.unloadDocument(document);
logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`);
} catch (unloadError: unknown) {
logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError);
logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`);
}
// STEP 7: VERIFY UNLOAD
const documentAfterUnload = instance.documents.get(pageId);
if (documentAfterUnload) {
logger.error(
`❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}`
);
} else {
logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`);
}
};
+10 -114
View File
@@ -1,134 +1,30 @@
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { OutgoingMessage } from "@hocuspocus/server";
// redis
import { redisManager } from "@/redis";
import { AdminCommand } from "@/types/admin-commands";
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
const getRedisClient = () => {
const redisClient = redisManager.getClient();
if (!redisClient) {
throw new AppError("Redis client not initialized");
throw new Error("Redis client not initialized");
}
return redisClient;
};
export class Redis extends HocuspocusRedis {
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
constructor() {
super({ redis: getRedisClient() });
}
async onConfigure(payload: onConfigurePayload) {
await super.onConfigure(payload);
// Subscribe to admin channel
await new Promise<void>((resolve, reject) => {
this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => {
if (error) {
logger.error(`[Redis] Failed to subscribe to admin channel:`, error);
reject(error);
} else {
logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`);
resolve();
}
});
});
// Listen for admin messages
this.sub.on("message", this.handleAdminMessage);
logger.info(`[Redis] Attached admin message listener`);
}
private handleAdminMessage = async (channel: string, message: string) => {
if (channel !== this.ADMIN_CHANNEL) return;
try {
const data = JSON.parse(message) as AdminCommandData;
// Validate command
if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) {
logger.warn(`[Redis] Invalid admin command received: ${data.command}`);
return;
}
const handler = this.adminHandlers.get(data.command);
if (handler) {
await handler(data);
} else {
logger.warn(`[Redis] No handler registered for admin command: ${data.command}`);
}
} catch (error) {
logger.error("[Redis] Error handling admin message:", error);
}
};
/**
* Register handler for an admin command
*/
public onAdminCommand<T extends AdminCommandData = AdminCommandData>(
command: AdminCommand,
handler: AdminCommandHandler<T>
) {
this.adminHandlers.set(command, handler as AdminCommandHandler);
logger.info(`[Redis] Registered admin command: ${command}`);
}
/**
* Publish admin command to global channel
*/
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
// Validate command data
if (!data.command || !Object.values(AdminCommand).includes(data.command)) {
throw new AppError(`Invalid admin command: ${data.command}`);
}
const message = JSON.stringify(data);
const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message);
logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`);
return receivers;
}
async onDestroy() {
// Unsubscribe from admin channel
await new Promise<void>((resolve) => {
this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => {
if (error) {
logger.error(`[Redis] Error unsubscribing from admin channel:`, error);
}
resolve();
});
});
// Remove the message listener to prevent memory leaks
this.sub.removeListener("message", this.handleAdminMessage);
logger.info(`[Redis] Removed admin message listener`);
await super.onDestroy();
}
/**
* Broadcast a message to a document across all servers via Redis.
* Uses empty identifier so ALL servers process the message.
*/
public async broadcastToDocument(documentName: string, payload: unknown): Promise<number> {
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
const channel = this["pubKey"](documentName);
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
const result = await this.pub.publishBuffer(channel, encodedMessage);
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
return result;
return this.pub.publish(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
// we're accessing the private method of the hocuspocus redis extension to encode the message
this["encodeMessage"](message.toUint8Array())
);
}
}
-50
View File
@@ -1,50 +0,0 @@
import type { Request, Response, NextFunction } from "express";
import { logger } from "@plane/logger";
import { env } from "@/env";
/**
* Express middleware to verify secret key authentication for protected endpoints
*
* Checks for secret key in headers:
* - x-admin-secret-key (preferred for admin endpoints)
* - live-server-secret-key (for backward compatibility)
*
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*
* @example
* ```typescript
* import { Middleware } from "@plane/decorators";
* import { requireSecretKey } from "@/lib/auth-middleware";
*
* @Get("/protected")
* @Middleware(requireSecretKey)
* async protectedEndpoint(req: Request, res: Response) {
* // This will only execute if secret key is valid
* }
* ```
*/
// TODO - Move to hmac
export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => {
const secretKey = req.headers["live-server-secret-key"];
if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) {
logger.warn(`
⚠️ [AUTH] Unauthorized access attempt
Endpoint: ${req.path}
Method: ${req.method}
IP: ${req.ip}
User-Agent: ${req.headers["user-agent"]}
`);
res.status(401).json({
error: "Unauthorized",
status: 401,
});
return;
}
// Secret key is valid, proceed to the route handler
next();
};
+6 -14
View File
@@ -2,7 +2,6 @@
import type { IncomingHttpHeaders } from "http";
import type { TUserDetails } from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// services
import { UserService } from "@/services/user.service";
// types
@@ -36,10 +35,8 @@ export const onAuthenticate = async ({
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
const appError = new AppError(error, {
context: { operation: "onAuthenticate" },
});
logger.error("Token parsing failed, using request headers", appError);
// If token parsing fails, fallback to request headers
logger.error("AUTH: Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
@@ -48,9 +45,7 @@ export const onAuthenticate = async ({
}
if (!cookie || !userId) {
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
logger.error("Credentials not provided", appError);
throw appError;
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
@@ -72,7 +67,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
const userService = new UserService();
const user = await userService.currentUser(cookie);
if (user.id !== userId) {
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
throw new Error("Authentication unsuccessful!");
}
return {
@@ -82,10 +77,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
},
};
} catch (error) {
const appError = new AppError(error, {
context: { operation: "handleAuthentication" },
});
logger.error("Authentication failed", appError);
throw new AppError("Authentication unsuccessful", { code: appError.code });
logger.error("AUTH: Token parsing failed, using request headers:", error);
throw Error("Authentication unsuccessful!");
}
};
-73
View File
@@ -1,73 +0,0 @@
import { AxiosError } from "axios";
/**
* Application error class that sanitizes and standardizes errors across the app.
* Extracts only essential information from AxiosError to prevent massive log bloat
* and sensitive data leaks (cookies, tokens, etc).
*
* Usage:
* new AppError("Simple error message")
* new AppError("Custom error", { code: "MY_CODE", statusCode: 400 })
* new AppError(axiosError) // Auto-extracts essential info
* new AppError(anyError) // Works with any error type
*/
export class AppError extends Error {
statusCode?: number;
method?: string;
url?: string;
code?: string;
context?: Record<string, any>;
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
// Handle error objects - extract essential info
const error = messageOrError;
// Already AppError - return immediately for performance (no need to re-process)
if (error instanceof AppError) {
return error;
}
// Handle string message (simple case like regular Error)
if (typeof messageOrError === "string") {
super(messageOrError);
this.name = "AppError";
if (data) {
Object.assign(this, data);
}
return;
}
// AxiosError - extract ONLY essential info (no config, no headers, no cookies)
if (error && typeof error === "object" && "isAxiosError" in error) {
const axiosError = error as AxiosError;
const responseData = axiosError.response?.data as any;
super(responseData?.message || axiosError.message);
this.name = "AppError";
this.statusCode = axiosError.response?.status;
this.method = axiosError.config?.method?.toUpperCase();
this.url = axiosError.config?.url;
this.code = axiosError.code;
return;
}
// DOMException (AbortError from cancelled requests)
if (error instanceof DOMException && error.name === "AbortError") {
super(error.message);
this.name = "AppError";
this.code = "ABORT_ERROR";
return;
}
// Standard Error objects
if (error instanceof Error) {
super(error.message);
this.name = "AppError";
this.code = error.name;
return;
}
// Unknown error types - safe fallback
super("Unknown error occurred");
this.name = "AppError";
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/
* @param param0
*/
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
+7 -10
View File
@@ -59,20 +59,13 @@ export class RedisManager {
return;
}
// Configuration optimized for BOTH regular operations AND pub/sub
// HocuspocusRedis uses .duplicate() which inherits these settings
this.redisClient = new Redis(redisUrl, {
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
lazyConnect: true,
keepAlive: 30000,
connectTimeout: 10000,
commandTimeout: 5000,
// enableOfflineQueue: false,
maxRetriesPerRequest: 3,
enableOfflineQueue: true, // Keep commands queued during reconnection
retryStrategy: (times: number) => {
// Exponential backoff with max 2 seconds
const delay = Math.min(times * 50, 2000);
logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`);
return delay;
},
});
// Set up event listeners
@@ -101,6 +94,10 @@ export class RedisManager {
this.isConnected = false;
});
// Connect to Redis
await this.redisClient.connect();
// Test the connection
await this.redisClient.ping();
logger.info("REDIS_MANAGER: Redis connection test successful");
} catch (error) {
+1
View File
@@ -39,6 +39,7 @@ export class Server {
const manager = HocusPocusServerManager.getInstance();
this.hocuspocusServer = await manager.initialize();
logger.info("SERVER: HocusPocus setup completed");
this.setupRoutes(this.hocuspocusServer);
this.setupNotFoundHandler();
} catch (error) {
+3 -2
View File
@@ -1,6 +1,6 @@
import axios, { AxiosInstance } from "axios";
import { logger } from "@plane/logger";
import { env } from "@/env";
import { AppError } from "@/lib/errors";
export abstract class APIService {
protected baseURL: string;
@@ -21,7 +21,8 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(new AppError(error));
logger.error("AXIOS_ERROR:", error);
return Promise.reject(error);
}
);
}
+9 -29
View File
@@ -1,7 +1,5 @@
import { logger } from "@plane/logger";
import { TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
@@ -23,11 +21,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "fetchDetails", pageId },
});
logger.error("Failed to fetch page details", appError);
throw appError;
throw error;
});
}
@@ -41,11 +35,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "fetchDescriptionBinary", pageId },
});
logger.error("Failed to fetch page description binary", appError);
throw appError;
throw error;
});
}
@@ -60,7 +50,7 @@ export abstract class PageCoreService extends APIService {
// Early abort check
if (abortSignal?.aborted) {
throw new AppError(new DOMException("Aborted", "AbortError"));
throw new DOMException("Aborted", "AbortError");
}
// Create an abort listener that will reject the pending promise
@@ -68,7 +58,7 @@ export abstract class PageCoreService extends APIService {
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new AppError(new DOMException("Aborted", "AbortError")));
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
}
@@ -76,22 +66,16 @@ export abstract class PageCoreService extends APIService {
try {
return await Promise.race([
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
this.patch(`${this.basePath}/pages/${pageId}`, data, {
headers: this.getHeader(),
signal: abortSignal,
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "updatePageProperties", pageId },
});
if (appError.code === "ABORT_ERROR") {
throw appError;
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
}
logger.error("Failed to update page properties", appError);
throw appError;
throw error;
}),
abortPromise,
]);
@@ -109,11 +93,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "updateDescriptionBinary", pageId },
});
logger.error("Failed to update page description binary", appError);
throw appError;
throw error;
});
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
import { AppError } from "@/lib/errors";
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
// services
import { ProjectPageService } from "./project-page.service";
@@ -12,5 +11,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
});
}
throw new AppError(`Invalid document type ${documentType} provided.`);
throw new Error(`Invalid document type ${documentType} provided.`);
};
@@ -1,4 +1,3 @@
import { AppError } from "@/lib/errors";
import { PageService } from "./extended.service";
interface ProjectPageServiceParams {
@@ -14,9 +13,9 @@ export class ProjectPageService extends PageService {
constructor(params: ProjectPageServiceParams) {
super();
const { workspaceSlug, projectId } = params;
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
// validate cookie
if (!params.cookie) throw new AppError("Cookie is required.");
if (!params.cookie) throw new Error("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
+1 -7
View File
@@ -1,8 +1,6 @@
// types
import { logger } from "@plane/logger";
import type { IUser } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "@/services/api.service";
export class UserService extends APIService {
@@ -24,11 +22,7 @@ export class UserService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "currentUser" },
});
logger.error("Failed to fetch current user", appError);
throw appError;
throw error;
});
}
}
+19 -33
View File
@@ -1,9 +1,8 @@
// eslint-disable-next-line import/order
import { setupSentry } from "./instrument";
setupSentry();
// eslint-disable-next-line import/order
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { Server } from "./server";
let server: Server;
@@ -21,41 +20,28 @@ async function startServer() {
startServer();
// Handle process signals
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: Error) => {
logger.error(`UNHANDLED REJECTION!`, err);
try {
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
process.exit(1);
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
}
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("Received SIGINT signal. Killing node process...");
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: Error) => {
logger.error(`UNCAUGHT EXCEPTION!`, err);
try {
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
process.exit(1);
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
}
process.exit(1);
});
process.on("unhandledRejection", (err: Error) => {
const error = new AppError(err);
logger.error(`[UNHANDLED_REJECTION]`, error);
});
process.on("uncaughtException", (err: Error) => {
const error = new AppError(err);
logger.error(`[UNCAUGHT_EXCEPTION]`, error);
});
-143
View File
@@ -1,143 +0,0 @@
/**
* Type-safe admin commands for server-to-server communication
*/
/**
* Force close error codes - reasons why a document is being force closed
*/
export enum ForceCloseReason {
CRITICAL_ERROR = "critical_error",
MEMORY_LEAK = "memory_leak",
DOCUMENT_TOO_LARGE = "document_too_large",
ADMIN_REQUEST = "admin_request",
SERVER_SHUTDOWN = "server_shutdown",
SECURITY_VIOLATION = "security_violation",
CORRUPTION_DETECTED = "corruption_detected",
}
/**
* WebSocket close codes
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
*/
export enum CloseCode {
/** Normal closure; the connection successfully completed */
NORMAL = 1000,
/** The endpoint is going away (server shutdown or browser navigating away) */
GOING_AWAY = 1001,
/** Protocol error */
PROTOCOL_ERROR = 1002,
/** Unsupported data */
UNSUPPORTED_DATA = 1003,
/** Reserved (no status code was present) */
NO_STATUS = 1005,
/** Abnormal closure */
ABNORMAL = 1006,
/** Invalid frame payload data */
INVALID_DATA = 1007,
/** Policy violation */
POLICY_VIOLATION = 1008,
/** Message too big */
MESSAGE_TOO_BIG = 1009,
/** Client expected extension not negotiated */
MANDATORY_EXTENSION = 1010,
/** Server encountered unexpected condition */
INTERNAL_ERROR = 1011,
/** Custom: Force close requested */
FORCE_CLOSE = 4000,
/** Custom: Document too large */
DOCUMENT_TOO_LARGE = 4001,
/** Custom: Memory pressure */
MEMORY_PRESSURE = 4002,
/** Custom: Security violation */
SECURITY_VIOLATION = 4003,
}
/**
* Admin command types
*/
export enum AdminCommand {
FORCE_CLOSE = "force_close",
HEALTH_CHECK = "health_check",
RESTART_DOCUMENT = "restart_document",
}
/**
* Force close command data structure
*/
export interface ForceCloseCommandData {
command: AdminCommand.FORCE_CLOSE;
docId: string;
reason: ForceCloseReason;
code: CloseCode;
originServer: string;
timestamp?: string;
}
/**
* Health check command data structure
*/
export interface HealthCheckCommandData {
command: AdminCommand.HEALTH_CHECK;
originServer: string;
timestamp: string;
}
/**
* Union type for all admin commands
*/
export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData;
/**
* Client force close message structure (sent to clients via sendStateless)
*/
export interface ClientForceCloseMessage {
type: "force_close";
reason: ForceCloseReason;
code: CloseCode;
message?: string;
timestamp?: string;
}
/**
* Admin command handler function type
*/
export type AdminCommandHandler<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | void;
/**
* Type guard to check if data is a ForceCloseCommandData
*/
export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData {
return data.command === AdminCommand.FORCE_CLOSE;
}
/**
* Type guard to check if data is a HealthCheckCommandData
*/
export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData {
return data.command === AdminCommand.HEALTH_CHECK;
}
/**
* Validate force close reason
*/
export function isValidForceCloseReason(reason: string): reason is ForceCloseReason {
return Object.values(ForceCloseReason).includes(reason as ForceCloseReason);
}
/**
* Get human-readable message for force close reason
*/
export function getForceCloseMessage(reason: ForceCloseReason): string {
const messages: Record<ForceCloseReason, string> = {
[ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.",
[ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.",
[ForceCloseReason.DOCUMENT_TOO_LARGE]:
"Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.",
[ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.",
[ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.",
[ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.",
[ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.",
};
return messages[reason] || "Connection closed. Please refresh the page.";
}
-38
View File
@@ -1,38 +0,0 @@
import { type Hocuspocus } from "@hocuspocus/server";
import { createRealtimeEvent } from "@plane/editor";
import { logger } from "@plane/logger";
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import { broadcastMessageToPage } from "./broadcast-message";
// Helper to broadcast error to frontend
export const broadcastError = async (
hocuspocusServerInstance: Hocuspocus,
pageId: string,
errorMessage: string,
errorType: "fetch" | "store",
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
errorCode?: "content_too_large" | "page_locked" | "page_archived",
shouldDisconnect?: boolean
) => {
try {
const errorEvent = createRealtimeEvent({
action: "error",
page_id: pageId,
parent_id: undefined,
descendants_ids: [],
data: {
error_message: errorMessage,
error_type: errorType,
error_code: errorCode,
should_disconnect: shouldDisconnect,
user_id: context.userId || "",
},
workspace_slug: context.workspaceSlug || "",
user_id: context.userId || "",
});
await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent);
} catch (broadcastError) {
logger.error("Error broadcasting error message to frontend:", broadcastError);
}
};
-34
View File
@@ -1,34 +0,0 @@
import { Hocuspocus } from "@hocuspocus/server";
import { BroadcastedEvent } from "@plane/editor";
import { logger } from "@plane/logger";
import { Redis } from "@/extensions/redis";
import { AppError } from "@/lib/errors";
export const broadcastMessageToPage = async (
hocuspocusServerInstance: Hocuspocus,
documentName: string,
eventData: BroadcastedEvent
): Promise<boolean> => {
if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) {
const appError = new AppError("HocusPocus server not available or initialized", {
context: { operation: "broadcastMessageToPage", documentName },
});
logger.error("Error while broadcasting message:", appError);
return false;
}
const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis);
if (!redisExtension) {
logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found");
return false;
}
try {
await redisExtension.broadcastToDocument(documentName, eventData);
return true;
} catch (error) {
logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error);
return false;
}
};
+80 -18
View File
@@ -1,21 +1,83 @@
export const generateTitleProsemirrorJson = (text: string) => {
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
// plane types
import { TDocumentPayload } from "@plane/types";
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
throw new Error(`Invalid variant provided: ${variant}`);
};
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
return Y.encodeStateAsUpdate(transformedData);
};
+9
View File
@@ -1,3 +1,5 @@
import DOMPurify from "dompurify";
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
const fallbackCopyTextToClipboard = (text: string) => {
@@ -48,6 +50,13 @@ export const checkEmailValidity = (email: string): boolean => {
return isEmailValid;
};
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
// Remove HTML tags using regex
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
// Trim the string and check if it's empty
return cleanText.trim() === "";
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.1.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -31,6 +31,7 @@
"axios": "catalog:",
"clsx": "^2.0.0",
"date-fns": "^4.1.0",
"dompurify": "^3.0.11",
"dotenv": "^16.3.1",
"lodash-es": "catalog:",
"lowlight": "^2.9.0",
@@ -6,11 +6,11 @@ import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
const FeaturesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
@@ -1 +0,0 @@
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
@@ -13,7 +13,6 @@ export type TProperties = {
isPro: boolean;
isEnabled: boolean;
renderChildren?: (currentProjectDetails: IProject, workspaceSlug: string) => ReactNode;
href?: string;
};
type TProjectBaseFeatureKeys = "cycles" | "modules" | "views" | "pages" | "inbox";
@@ -33,7 +33,7 @@ type Props = {
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
workspaceLevelToggle?: boolean;
shouldHideIssue?: (issue: ISearchIssueResponse) => boolean;
selectedWorkItemIds?: string[];
selectedWorkItems?: ISearchIssueResponse[];
workItemSearchServiceCallback?: (params: TProjectIssuesSearchParams) => Promise<ISearchIssueResponse[]>;
};
@@ -51,7 +51,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
handleOnSubmit,
workspaceLevelToggle = false,
shouldHideIssue,
selectedWorkItemIds,
selectedWorkItems,
workItemSearchServiceCallback,
} = props;
// states
@@ -117,10 +117,10 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
};
useEffect(() => {
if (selectedWorkItemIds) {
setSelectedIssues(issues.filter((issue) => selectedWorkItemIds.includes(issue.id)));
if (selectedWorkItems) {
setSelectedIssues(selectedWorkItems);
}
}, [isOpen, selectedWorkItemIds]);
}, [isOpen, selectedWorkItems]);
useEffect(() => {
handleSearch();
@@ -177,7 +177,6 @@ export const MemberDropdownBase: React.FC<TMemberDropdownBaseProps> = observer((
optionsClassName={optionsClassName}
placement={placement}
referenceElement={referenceElement}
selectedMemberIds={Array.isArray(value) ? value : value ? [value] : []}
/>
)}
</ComboDropDown>
@@ -29,7 +29,6 @@ interface Props {
optionsClassName?: string;
placement: Placement | undefined;
referenceElement: HTMLButtonElement | null;
selectedMemberIds?: string[];
}
export const MemberOptions: React.FC<Props> = observer((props: Props) => {
@@ -41,7 +40,6 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
optionsClassName = "",
placement,
referenceElement,
selectedMemberIds = [],
} = props;
// router
const { workspaceSlug } = useParams();
@@ -87,14 +85,8 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
}
};
const selectedMemberIdsSet = new Set(selectedMemberIds);
const options = memberIds
?.map((userId) => {
const isSuspended = isUserSuspended(userId, workspaceSlug?.toString());
if (isSuspended && !selectedMemberIdsSet.has(userId)) return null;
const userDetails = getUserDetails(userId);
return {
value: userId,
@@ -102,30 +94,25 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
content: (
<div className="flex items-center gap-2">
<div className="w-4">
{isSuspended ? (
{isUserSuspended(userId, workspaceSlug?.toString()) ? (
<SuspendedUserIcon className="h-3.5 w-3.5 text-custom-text-400" />
) : (
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
)}
</div>
<span
className={cn("flex-grow truncate", isSuspended ? "text-custom-text-400" : "")}
className={cn(
"flex-grow truncate",
isUserSuspended(userId, workspaceSlug?.toString()) ? "text-custom-text-400" : ""
)}
>
{currentUser?.id === userId ? t("you") : userDetails?.display_name}
</span>
</div>
),
isSuspended,
};
})
.filter((o): o is NonNullable<typeof o> => !!o)
?.sort((a, b) => {
const isASelected = selectedMemberIdsSet.has(a.value);
const isBSelected = selectedMemberIdsSet.has(b.value);
if (isASelected === isBSelected) return 0;
return isASelected ? -1 : 1;
});
.filter((o) => !!o);
const filteredOptions =
query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
@@ -170,16 +157,18 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
"flex w-full select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5",
active && "bg-custom-background-80",
selected ? "text-custom-text-100" : "text-custom-text-200",
option.isSuspended && !selected ? "cursor-not-allowed" : "cursor-pointer"
isUserSuspended(option.value, workspaceSlug?.toString())
? "cursor-not-allowed"
: "cursor-pointer"
)
}
disabled={option.isSuspended && !selectedMemberIdsSet.has(option.value)}
disabled={isUserSuspended(option.value, workspaceSlug?.toString())}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
{option.isSuspended && (
{isUserSuspended(option.value, workspaceSlug?.toString()) && (
<Pill variant={EPillVariant.DEFAULT} size={EPillSize.XS} className="border-none">
Suspended
</Pill>
@@ -319,7 +319,6 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
{
name: watch("name"),
description_html: getTextContent(watch("description_html")),
issueId: data?.id,
}
);
@@ -54,6 +54,7 @@ export type TPageActions =
| "move";
type Props = {
editorRef?: EditorRefApi | null;
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
optionsOrder: TPageActions[];
page: TPageInstance;
@@ -62,7 +63,7 @@ type Props = {
};
export const PageActions: React.FC<Props> = observer((props) => {
const { extraOptions, optionsOrder, page, parentRef, storeType } = props;
const { editorRef, extraOptions, optionsOrder, page, parentRef, storeType } = props;
// states
const [deletePageModal, setDeletePageModal] = useState(false);
const [movePageModal, setMovePageModal] = useState(false);
@@ -74,6 +75,7 @@ export const PageActions: React.FC<Props> = observer((props) => {
});
// page operations
const { pageOperations } = usePageOperations({
editorRef,
page,
});
// derived values
@@ -130,6 +130,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
pageTitle={name ?? ""}
/>
<PageActions
editorRef={editorRef}
extraOptions={EXTRA_MENU_OPTIONS}
optionsOrder={[
"full-screen",
@@ -44,7 +44,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((pr
const handleDelete = async () => {
setIsDeleting(true);
await removePage({ pageId })
await removePage(pageId)
.then(() => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
@@ -3,11 +3,12 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_TRACKER_ELEMENTS, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { IProject } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
// components
import { SettingsHeading } from "@/components/settings/heading";
// helpers
@@ -18,7 +19,6 @@ import { useUser } from "@/hooks/store/user";
// plane web imports
import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge";
import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings";
import { ProjectFeatureToggle } from "./helper";
type Props = {
workspaceSlug: string;
@@ -96,13 +96,12 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
</p>
</div>
</div>
<ProjectFeatureToggle
workspaceSlug={workspaceSlug}
projectId={projectId}
featureItem={featureItem}
<ToggleSwitch
value={Boolean(currentProjectDetails?.[featureItem.property as keyof IProject])}
handleSubmit={handleSubmit}
disabled={!isAdmin}
onChange={() => handleSubmit(featureItemKey, featureItem.property)}
disabled={!featureItem.isEnabled || !isAdmin}
size="sm"
data-ph-element={PROJECT_TRACKER_ELEMENTS.TOGGLE_FEATURE}
/>
</div>
<div className="pl-14">
@@ -1,41 +0,0 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { EPillVariant, Pill, EPillSize } from "@plane/propel/pill";
import { ToggleSwitch } from "@plane/ui";
import type { TProperties } from "@/plane-web/constants/project/settings/features";
type Props = {
workspaceSlug: string;
projectId: string;
featureItem: TProperties;
value: boolean;
handleSubmit: (featureKey: string, featureProperty: string) => void;
disabled?: boolean;
};
export const ProjectFeatureToggle = (props: Props) => {
const { workspaceSlug, projectId, featureItem, value, handleSubmit, disabled } = props;
return featureItem.href ? (
<Link href={`/${workspaceSlug}/settings/projects/${projectId}/features/${featureItem.href}`}>
<div className="flex items-center gap-2">
<Pill
variant={value ? EPillVariant.PRIMARY : EPillVariant.DEFAULT}
size={EPillSize.SM}
className="border-none rounded-lg"
>
{value ? "Enabled" : "Disabled"}
</Pill>
<ChevronRight className="h-4 w-4 text-custom-text-300" />
</div>
</Link>
) : (
<ToggleSwitch
value={value}
onChange={() => handleSubmit(featureItem.key, featureItem.property)}
disabled={disabled}
size="sm"
data-ph-element={PROJECT_TRACKER_ELEMENTS.TOGGLE_FEATURE}
/>
);
};
@@ -1,5 +1,5 @@
import type { FC } from "react";
import React from "react";
import type { FC } from "react";
import { cn } from "@plane/utils";
type Props = React.ComponentProps<"button"> & {
@@ -1,5 +1,5 @@
import type { FC } from "react";
import React from "react";
import type { FC } from "react";
import { Search } from "lucide-react";
import { cn } from "@plane/utils";
@@ -6,51 +6,51 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// store
import type { TPageInstance } from "@/store/pages/base-page";
export type CollaborativeAction = {
execute: (shouldSync?: boolean, recursive?: boolean) => Promise<void>;
// Better type naming and structure
type CollaborativeAction = {
execute: (shouldSync?: boolean) => Promise<void>;
errorMessage: string;
};
type CollaborativeActionEvent =
| { type: "sendMessageToServer"; message: TDocumentEventsServer; recursive?: boolean }
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
type Props = {
editorRef?: EditorRefApi | null;
page: TPageInstance;
};
export const useCollaborativePageActions = (props: Props) => {
const { page } = props;
const editorRef = page.editor.editorRef;
const { editorRef, page } = props;
// currentUserAction local state to track if the current action is being processed, a
// local action is basically the action performed by the current user to avoid double operations
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
// @ts-expect-error - TODO: fix this
const actionHandlerMap: Record<TDocumentEventsClient, CollaborativeAction> = useMemo(
() => ({
[DocumentCollaborativeEvents.lock.client]: {
execute: (shouldSync?: boolean, recursive?: boolean) => page.lock({ shouldSync, recursive }),
execute: (shouldSync) => page.lock(shouldSync),
errorMessage: "Page could not be locked. Please try again later.",
},
[DocumentCollaborativeEvents.unlock.client]: {
execute: (shouldSync?: boolean, recursive?: boolean) => page.unlock({ shouldSync, recursive }),
execute: (shouldSync) => page.unlock(shouldSync),
errorMessage: "Page could not be unlocked. Please try again later.",
},
[DocumentCollaborativeEvents.archive.client]: {
execute: (shouldSync?: boolean) => page.archive({ shouldSync }),
execute: (shouldSync) => page.archive(shouldSync),
errorMessage: "Page could not be archived. Please try again later.",
},
[DocumentCollaborativeEvents.unarchive.client]: {
execute: (shouldSync?: boolean) => page.restore({ shouldSync }),
execute: (shouldSync) => page.restore(shouldSync),
errorMessage: "Page could not be restored. Please try again later.",
},
[DocumentCollaborativeEvents["make-public"].client]: {
execute: (shouldSync?: boolean) => page.makePublic({ shouldSync }),
execute: (shouldSync) => page.makePublic(shouldSync),
errorMessage: "Page could not be made public. Please try again later.",
},
[DocumentCollaborativeEvents["make-private"].client]: {
execute: (shouldSync?: boolean) => page.makePrivate({ shouldSync }),
execute: (shouldSync) => page.makePrivate(shouldSync),
errorMessage: "Page could not be made private. Please try again later.",
},
}),
@@ -62,22 +62,22 @@ export const useCollaborativePageActions = (props: Props) => {
const isPerformedByCurrentUser = event.type === "sendMessageToServer";
const clientAction = isPerformedByCurrentUser ? DocumentCollaborativeEvents[event.message].client : event.message;
const actionDetails = actionHandlerMap[clientAction];
try {
await actionDetails.execute(isPerformedByCurrentUser, isPerformedByCurrentUser ? event?.recursive : undefined);
await actionDetails.execute(isPerformedByCurrentUser);
if (isPerformedByCurrentUser) {
const serverEventName = getServerEventName(clientAction);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
setCurrentActionBeingProcessed(clientAction);
}
} catch {
if (actionDetails?.errorMessage) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: actionDetails.errorMessage,
});
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: actionDetails.errorMessage,
});
}
},
[actionHandlerMap, editorRef]
@@ -25,6 +25,7 @@ export type TPageOperations = {
};
type Props = {
editorRef?: EditorRefApi | null;
page: TPageInstance;
};
@@ -1,193 +0,0 @@
import { useCallback, useMemo } from "react";
// plane imports
import type { EventToPayloadMap } from "@plane/editor";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
// types
import type { IUserLite } from "@plane/types";
// components
import type { TEditorBodyHandlers } from "@/components/pages/editor/editor-body";
// hooks
import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
// Type for page update handlers with proper typing for action data
export type PageUpdateHandler<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = (params: {
pageIds: string[];
data: EventToPayloadMap[T];
performAction: boolean;
}) => void;
// Type for custom event handlers that can be provided to override default behavior
export type TCustomEventHandlers = {
[K in keyof EventToPayloadMap]?: PageUpdateHandler<K>;
};
interface UsePageEventsProps {
page: TPageInstance;
storeType: EPageStoreType;
getUserDetails: (userId: string) => IUserLite | undefined;
customRealtimeEventHandlers?: TCustomEventHandlers;
handlers: TEditorBodyHandlers;
}
export const useRealtimePageEvents = ({
page,
storeType,
getUserDetails,
customRealtimeEventHandlers,
handlers,
}: UsePageEventsProps) => {
const router = useAppRouter();
const { removePage, getPageById } = usePageStore(storeType);
const { data: currentUser } = useUser();
// Helper function to safely get user display text
const getUserDisplayText = useCallback(
(userId: string | undefined) => {
if (!userId) return "";
try {
const userDetails = getUserDetails(userId as string);
return userDetails?.display_name ? ` by ${userDetails.display_name}` : "";
} catch {
return "";
}
},
[getUserDetails]
);
const ACTION_HANDLERS = useMemo<
Partial<{
[K in keyof EventToPayloadMap]: PageUpdateHandler<K>;
}>
>(
() => ({
archived: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.archive({ archived_at: data.archived_at, shouldSync: false });
});
},
unarchived: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.restore({ shouldSync: false });
});
},
locked: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.lock({ shouldSync: false, recursive: false });
});
},
unlocked: ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.unlock({ shouldSync: false, recursive: false });
});
},
"made-public": ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.makePublic({ shouldSync: false });
});
},
"made-private": ({ pageIds }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) pageItem.makePrivate({ shouldSync: false });
});
},
deleted: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageItem = getPageById(pageId);
if (pageItem) {
removePage({ pageId, shouldSync: false });
if (page.id === pageId && data?.user_id !== currentUser?.id) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Page deleted",
message: `Page deleted${getUserDisplayText(data.user_id)}`,
});
router.push(handlers.getRedirectionLink());
} else if (page.id === pageId) {
router.push(handlers.getRedirectionLink());
}
}
});
},
property_updated: ({ pageIds, data }) => {
pageIds.forEach((pageId) => {
const pageInstance = getPageById(pageId);
const { name: updatedName, ...rest } = data;
if (updatedName != null) pageInstance?.updateTitle(updatedName);
pageInstance?.mutateProperties(rest);
});
},
error: ({ pageIds, data }) => {
const errorType = data.error_type;
const errorMessage = data.error_message || "An error occurred";
const errorCode = data.error_code;
if (page.id && pageIds.includes(page.id)) {
// Show toast notification
setToast({
type: TOAST_TYPE.ERROR,
title: errorType === "fetch" ? "Failed to load page" : "Failed to save page",
message: errorMessage,
});
// Handle specific error codes
const pageInstance = getPageById(page.id);
if (pageInstance) {
if (errorCode === "page_locked") {
// Lock the page if not already locked
if (!pageInstance.is_locked) {
pageInstance.mutateProperties({ is_locked: true });
}
} else if (errorCode === "page_archived") {
// Mark page as archived if not already
if (!pageInstance.archived_at) {
pageInstance.mutateProperties({ archived_at: new Date().toISOString() });
}
}
}
}
},
...customRealtimeEventHandlers,
}),
[getPageById, page, router, getUserDisplayText, removePage, currentUser, customRealtimeEventHandlers, handlers]
);
// The main function that will be returned from this hook
const updatePageProperties = useCallback(
<T extends keyof EventToPayloadMap>(
pageIds: string | string[],
actionType: T,
data: EventToPayloadMap[T],
performAction = false
) => {
// Convert to array if single string is passed
const normalizedPageIds = Array.isArray(pageIds) ? pageIds : [pageIds];
if (normalizedPageIds.length === 0) return;
// Get the handler for this message type
const handler = ACTION_HANDLERS[actionType];
if (handler) {
// Now TypeScript knows that handler and data match in type
handler({ pageIds: normalizedPageIds, data, performAction });
} else {
console.warn(`No handler for message type: ${actionType.toString()}`);
}
},
[ACTION_HANDLERS]
);
return { updatePageProperties };
};
@@ -1,13 +1,10 @@
import { observable, action, makeObservable, runInAction, computed, reaction } from "mobx";
import { observable, action, makeObservable, runInAction, computed } from "mobx";
// helpers
import { computedFn } from "mobx-utils";
import type { ICalendarPayload, ICalendarWeek } from "@plane/types";
import { EStartOfTheWeek } from "@plane/types";
import { generateCalendarData, getWeekNumberOfDate } from "@plane/utils";
// types
import type { IIssueRootStore } from "./root.store";
export interface ICalendarStore {
calendarFilters: {
activeMonthDate: Date;
@@ -18,7 +15,6 @@ export interface ICalendarStore {
// action
updateCalendarFilters: (filters: Partial<{ activeMonthDate: Date; activeWeekDate: Date }>) => void;
updateCalendarPayload: (date: Date) => void;
regenerateCalendar: () => void;
// computed
allWeeksOfActiveMonth:
@@ -42,10 +38,8 @@ export class CalendarStore implements ICalendarStore {
activeWeekDate: new Date(),
};
calendarPayload: ICalendarPayload | null = null;
// root store
rootStore;
constructor(_rootStore: IIssueRootStore) {
constructor() {
makeObservable(this, {
loader: observable.ref,
error: observable.ref,
@@ -57,7 +51,6 @@ export class CalendarStore implements ICalendarStore {
// actions
updateCalendarFilters: action,
updateCalendarPayload: action,
regenerateCalendar: action,
//computed
allWeeksOfActiveMonth: computed,
@@ -65,17 +58,7 @@ export class CalendarStore implements ICalendarStore {
allDaysOfActiveWeek: computed,
});
this.rootStore = _rootStore;
this.initCalendar();
// Watch for changes in startOfWeek preference and regenerate calendar
reaction(
() => this.rootStore.rootStore.user.userProfile.data?.start_of_the_week,
() => {
// Regenerate calendar when startOfWeek preference changes
this.regenerateCalendar();
}
);
}
get allWeeksOfActiveMonth() {
@@ -155,32 +138,14 @@ export class CalendarStore implements ICalendarStore {
if (!this.calendarPayload) return null;
const nextDate = new Date(date);
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
runInAction(() => {
this.calendarPayload = generateCalendarData(this.calendarPayload, nextDate, startOfWeek);
this.calendarPayload = generateCalendarData(this.calendarPayload, nextDate);
});
};
initCalendar = () => {
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
const newCalendarPayload = generateCalendarData(null, new Date(), startOfWeek);
runInAction(() => {
this.calendarPayload = newCalendarPayload;
});
};
/**
* Force complete regeneration of calendar data
* This should be called when startOfWeek preference changes
*/
regenerateCalendar = () => {
const startOfWeek = this.rootStore.rootStore.user.userProfile.data?.start_of_the_week ?? EStartOfTheWeek.SUNDAY;
const { activeMonthDate } = this.calendarFilters;
// Force complete regeneration by passing null to clear all cached data
const newCalendarPayload = generateCalendarData(null, activeMonthDate, startOfWeek);
const newCalendarPayload = generateCalendarData(null, new Date());
runInAction(() => {
this.calendarPayload = newCalendarPayload;
+1 -1
View File
@@ -266,7 +266,7 @@ export class IssueRootStore implements IIssueRootStore {
this.archivedIssues = new ArchivedIssues(this, this.archivedIssuesFilter);
this.issueKanBanView = new IssueKanBanViewStore(this);
this.issueCalendarView = new CalendarStore(this);
this.issueCalendarView = new CalendarStore();
this.projectEpicsFilter = new ProjectEpicsFilter(this);
this.projectEpics = new ProjectEpics(this, this.projectEpicsFilter);
+13 -14
View File
@@ -25,12 +25,12 @@ export type TBasePage = TPage & {
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
updateTitle: (title: string) => void;
updateDescription: (document: TDocumentPayload) => Promise<void>;
makePublic: (params: { shouldSync?: boolean }) => Promise<void>;
makePrivate: (params: { shouldSync?: boolean }) => Promise<void>;
lock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
unlock: (params: { shouldSync?: boolean; recursive?: boolean }) => Promise<void>;
archive: (params: { shouldSync?: boolean; archived_at?: string | null }) => Promise<void>;
restore: (params: { shouldSync?: boolean }) => Promise<void>;
makePublic: (shouldSync?: boolean) => Promise<void>;
makePrivate: (shouldSync?: boolean) => Promise<void>;
lock: (shouldSync?: boolean) => Promise<void>;
unlock: (shouldSync?: boolean) => Promise<void>;
archive: (shouldSync?: boolean) => Promise<void>;
restore: (shouldSync?: boolean) => Promise<void>;
updatePageLogo: (value: TChangeHandlerProps) => Promise<void>;
addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>;
@@ -315,7 +315,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description make the page public
*/
makePublic = async ({ shouldSync = true }) => {
makePublic = async (shouldSync: boolean = true) => {
const pageAccess = this.access;
runInAction(() => {
this.access = EPageAccess.PUBLIC;
@@ -338,7 +338,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description make the page private
*/
makePrivate = async ({ shouldSync = true }) => {
makePrivate = async (shouldSync: boolean = true) => {
const pageAccess = this.access;
runInAction(() => {
this.access = EPageAccess.PRIVATE;
@@ -361,7 +361,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description lock the page
*/
lock = async ({ shouldSync = true }) => {
lock = async (shouldSync: boolean = true) => {
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = true));
@@ -378,7 +378,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description unlock the page
*/
unlock = async ({ shouldSync = true }) => {
unlock = async (shouldSync: boolean = true) => {
const pageIsLocked = this.is_locked;
runInAction(() => (this.is_locked = false));
@@ -395,12 +395,12 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description archive the page
*/
archive = async ({ shouldSync = true, archived_at }: { shouldSync?: boolean; archived_at?: string | null }) => {
archive = async (shouldSync: boolean = true) => {
if (!this.id) return undefined;
try {
runInAction(() => {
this.archived_at = archived_at ?? new Date().toISOString();
this.archived_at = new Date().toISOString();
});
if (this.rootStore.favorite.entityMap[this.id]) this.rootStore.favorite.removeFavoriteFromStore(this.id);
@@ -422,7 +422,7 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
/**
* @description restore the page
*/
restore = async ({ shouldSync = true }: { shouldSync?: boolean }) => {
restore = async (shouldSync: boolean = true) => {
const archivedAtBeforeRestore = this.archived_at;
try {
@@ -438,7 +438,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
runInAction(() => {
this.archived_at = archivedAtBeforeRestore;
});
throw error;
}
};
@@ -57,7 +57,7 @@ export interface IProjectPageStore {
options?: { trackVisit?: boolean }
) => Promise<TPage | undefined>;
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
removePage: (params: { pageId: string; shouldSync?: boolean }) => Promise<void>;
removePage: (pageId: string) => Promise<void>;
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
}
@@ -321,7 +321,7 @@ export class ProjectPageStore implements IProjectPageStore {
* @description delete a page
* @param {string} pageId
*/
removePage = async ({ pageId, shouldSync = true }: { pageId: string; shouldSync?: boolean }) => {
removePage = async (pageId: string) => {
try {
const { workspaceSlug, projectId } = this.store.router;
if (!workspaceSlug || !projectId || !pageId) return undefined;
+1 -1
View File
@@ -12,7 +12,7 @@ import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/se
// store
import type { CoreRootStore } from "../root.store";
type ProjectOverviewCollapsible = "links" | "attachments" | "milestones";
type ProjectOverviewCollapsible = "links" | "attachments";
export interface IProjectStore {
// observables
@@ -1 +0,0 @@
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.1.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -42,6 +42,7 @@
"dotenv": "^16.0.3",
"emoji-picker-react": "^4.5.16",
"export-to-csv": "^1.4.0",
"isomorphic-dompurify": "^2.12.0",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"mobx": "catalog:",
@@ -44,7 +44,6 @@ x-mq-env: &mq-env # RabbitMQ Settings
x-live-env: &live-env
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
@@ -57,7 +56,6 @@ x-app-env: &app-env
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
LIVE_SERVER_SECRET_KEY: ${LIVE_SERVER_SECRET_KEY:-2FiJk1U2aiVPEQtzLehYGlTSnTnrs7LW}
services:
web:
-4
View File
@@ -76,7 +76,3 @@ MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT=60/minute
# Live server environment variables
# WARNING: You must set a secure value for LIVE_SERVER_SECRET_KEY in production environments.
LIVE_SERVER_SECRET_KEY=
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "1.1.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
+18 -20
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "1.1.0",
"version": "1.0.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -38,31 +38,29 @@
"@floating-ui/dom": "^1.7.1",
"@floating-ui/react": "^0.26.4",
"@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "2.15.2",
"@hocuspocus/provider": "3.2.5",
"@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*",
"@plane/types": "workspace:*",
"@plane/ui": "workspace:*",
"@plane/utils": "workspace:*",
"@tiptap/core": "catalog:",
"@tiptap/extension-blockquote": "^2.22.3",
"@tiptap/extension-character-count": "^2.22.3",
"@tiptap/extension-collaboration": "^2.22.3",
"@tiptap/extension-emoji": "^2.22.3",
"@tiptap/extension-image": "^2.22.3",
"@tiptap/extension-list-item": "^2.22.3",
"@tiptap/extension-mention": "^2.22.3",
"@tiptap/extension-placeholder": "^2.22.3",
"@tiptap/extension-task-item": "^2.22.3",
"@tiptap/extension-task-list": "^2.22.3",
"@tiptap/extension-text-align": "^2.22.3",
"@tiptap/extension-text-style": "^2.22.3",
"@tiptap/extension-underline": "^2.22.3",
"@tiptap/extension-blockquote": "^3.5.3",
"@tiptap/extension-collaboration": "^3.5.3",
"@tiptap/extension-emoji": "^3.5.3",
"@tiptap/extension-image": "^3.5.3",
"@tiptap/extension-list-item": "^3.5.3",
"@tiptap/extension-mention": "^3.5.3",
"@tiptap/extension-task-item": "^3.5.3",
"@tiptap/extension-task-list": "^3.5.3",
"@tiptap/extension-text-align": "^3.5.3",
"@tiptap/extension-text-style": "^3.5.3",
"@tiptap/extensions": "^3.5.3",
"@tiptap/html": "catalog:",
"@tiptap/pm": "^2.22.3",
"@tiptap/react": "^2.22.3",
"@tiptap/starter-kit": "^2.22.3",
"@tiptap/suggestion": "^2.22.3",
"@tiptap/pm": "^3.5.3",
"@tiptap/react": "^3.5.3",
"@tiptap/starter-kit": "^3.5.3",
"@tiptap/suggestion": "^3.5.3",
"emoji-regex": "^10.3.0",
"highlight.js": "^11.8.0",
"is-emoji-supported": "^0.0.5",
@@ -72,7 +70,7 @@
"lucide-react": "catalog:",
"prosemirror-codemark": "^0.4.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tiptap-markdown": "^0.9.0",
"uuid": "catalog:",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.15",
@@ -1,5 +1,6 @@
import { type Editor, isNodeSelection } from "@tiptap/core";
import { BubbleMenu, type BubbleMenuProps, useEditorState } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
@@ -118,10 +119,7 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
options: {
onShow: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = true;
@@ -136,15 +134,13 @@ export const EditorBubbleMenu: FC<Props> = (props) => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
onHidden: () => {
if (editor.storage.link) {
editor.storage.link.isBubbleMenuOpen = false;
}
setTimeout(() => {
editor.commands.removeActiveDropbarExtension("bubble-menu");
}, 0);
},
},
// TODO: Migrate these to floating UI options
// tippyOptions: {
// moveTransition: "transform 0.15s ease-out",
// duration: [300, 0],
// zIndex: 9,
// },
};
useEffect(() => {
@@ -1,91 +1,8 @@
import { EPageAccess } from "@plane/constants";
import { TPage } from "@plane/types";
import { CreatePayload, BaseActionPayload } from "@/types";
// Define all payload types for each event.
export type ArchivedPayload = CreatePayload<{ archived_at: string | null }>;
export type UnarchivedPayload = BaseActionPayload;
export type LockedPayload = CreatePayload<{ is_locked: boolean }>;
export type UnlockedPayload = BaseActionPayload;
export type MadePublicPayload = CreatePayload<{ access: EPageAccess }>;
export type MadePrivatePayload = CreatePayload<{ access: EPageAccess }>;
export type DeletedPayload = CreatePayload<{ deleted_at: Date | null }>;
export type DuplicatedPayload = CreatePayload<{ new_page_id: string }>;
export type PropertyUpdatedPayload = CreatePayload<Partial<TPage>>;
export type MovedPayload = CreatePayload<{
new_project_id: string;
new_page_id: string;
}>;
export type RestoredPayload = CreatePayload<{ deleted_page_ids?: string[] }>;
export type ErrorPayload = CreatePayload<{
error_message: string;
error_type: "fetch" | "store";
error_code?: "content_too_large" | "page_locked" | "page_archived";
should_disconnect?: boolean;
}>;
// Enhanced DocumentCollaborativeEvents with payload types.
// Both the client name and server name are defined, and we add a "payloadType" property
// so that we can later derive a mapping from client event to payload type.
export const DocumentCollaborativeEvents = {
lock: {
client: "locked",
server: "lock",
payloadType: {} as LockedPayload,
},
unlock: {
client: "unlocked",
server: "unlock",
payloadType: {} as UnlockedPayload,
},
archive: {
client: "archived",
server: "archive",
payloadType: {} as ArchivedPayload,
},
unarchive: {
client: "unarchived",
server: "unarchive",
payloadType: {} as UnarchivedPayload,
},
"make-public": {
client: "made-public",
server: "make-public",
payloadType: {} as MadePublicPayload,
},
"make-private": {
client: "made-private",
server: "make-private",
payloadType: {} as MadePrivatePayload,
},
delete: {
client: "deleted",
server: "delete",
payloadType: {} as DeletedPayload,
},
move: {
client: "moved",
server: "move",
payloadType: {} as MovedPayload,
},
duplicate: {
client: "duplicated",
server: "duplicate",
payloadType: {} as DuplicatedPayload,
},
property_update: {
client: "property_updated",
server: "property_update",
payloadType: {} as PropertyUpdatedPayload,
},
restore: {
client: "restored",
server: "restore",
payloadType: {} as RestoredPayload,
},
error: {
client: "error",
server: "error",
payloadType: {} as ErrorPayload,
},
lock: { client: "locked", server: "lock" },
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
} as const;
@@ -56,7 +56,7 @@ export const CodeBlockComponent: React.FC<Props> = ({ node }) => {
</Tooltip>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-4 my-2">
<NodeViewContent as="code" className="whitespace-pre-wrap" />
<NodeViewContent<"code"> as="code" className="whitespace-pre-wrap" />
</pre>
</NodeViewWrapper>
);
@@ -1,7 +1,6 @@
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { TextStyle } from "@tiptap/extension-text-style";
import { Underline } from "@tiptap/extension-underline";
// plane editor imports
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
// extensions
@@ -31,7 +30,6 @@ export const CoreEditorExtensionsWithoutProps = [
CustomLinkExtension,
ImageExtensionConfig,
CustomImageExtensionConfig,
Underline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
@@ -50,32 +50,6 @@ type LinkOptions = {
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_LINK]: {
/**
* Set a link mark
*/
setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
};
}
interface Storage {
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
}
@@ -1,4 +1,4 @@
import type { EmojiOptions, EmojiStorage } from "@tiptap/extension-emoji";
import type { EmojiOptions } from "@tiptap/extension-emoji";
import { ReactRenderer, type Editor } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -12,7 +12,7 @@ const DEFAULT_EMOJIS = ["+1", "-1", "smile", "orange_heart", "eyes"];
export const emojiSuggestion: EmojiOptions["suggestion"] = {
items: ({ editor, query }: { editor: Editor; query: string }): EmojiItem[] => {
const { emojis, isSupported } = editor.storage.emoji as EmojiStorage;
const { emojis, isSupported } = editor.storage.emoji;
const filteredEmojis = emojis.filter((emoji) => {
const hasEmoji = !!emoji?.emoji;
const hasFallbackImage = !!emoji?.fallbackImage;
@@ -79,7 +79,7 @@ export const emojiSuggestion: EmojiOptions["suggestion"] = {
component.updateProps(props);
if (!props.clientRect) return;
cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
},
onKeyDown: ({ event }) => {
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
@@ -1,9 +1,8 @@
import { Extensions } from "@tiptap/core";
import { CharacterCount } from "@tiptap/extension-character-count";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { TextStyle } from "@tiptap/extension-text-style";
import { Underline } from "@tiptap/extension-underline";
import { CharacterCount } from "@tiptap/extensions";
import { Markdown } from "tiptap-markdown";
// extensions
import {
@@ -76,7 +75,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
ListKeymap({ tabIndex }),
CustomLinkExtension,
CustomTypographyExtension,
Underline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
@@ -52,6 +52,7 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
this.editor.emit("update", {
editor: this.editor,
transaction: newState.tr,
appendedTransactions: [],
});
return null;
@@ -60,8 +61,4 @@ export const HeadingListExtension = Extension.create<unknown, HeadingExtensionSt
return [plugin];
},
getHeadings() {
return this.storage.headings;
},
});
@@ -48,7 +48,7 @@ export const renderMentionsDropdown =
component.updateProps(props);
if (!props.clientRect) return;
cleanup();
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element as HTMLElement).cleanup;
cleanup = updateFloatingUIFloaterPosition(props.editor, component.element).cleanup;
},
onKeyDown: ({ event }) => {
if ([...DROPDOWN_NAVIGATION_KEYS, "Escape"].includes(event.key)) {
@@ -1,4 +1,4 @@
import { Placeholder } from "@tiptap/extension-placeholder";
import { Placeholder } from "@tiptap/extensions";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// types
@@ -27,6 +27,8 @@ export const CustomStarterKitExtension = (args: TArgs) => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
link: false,
listKeymap: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
@@ -41,6 +43,6 @@ export const CustomStarterKitExtension = (args: TArgs) => {
class:
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
},
...(enableHistory ? {} : { history: false }),
...(enableHistory ? {} : { undoRedo: false }),
});
};
@@ -94,8 +94,11 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
?.chain()
.setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true)
.setMeta(CORE_EDITOR_META.INTENTIONAL_DELETION, true)
.setContent(content, emitUpdate, {
preserveWhitespace: true,
.setContent(content, {
emitUpdate,
parseOptions: {
preserveWhitespace: true,
},
})
.run();
},
+5 -2
View File
@@ -93,8 +93,11 @@ export const useEditor = (props: TEditorHookProps) => {
const { uploadInProgress: isUploadInProgress } = editor.storage.utility;
if (!editor.isDestroyed && !isUploadInProgress) {
try {
editor.commands.setContent(value, false, {
preserveWhitespace: true,
editor.commands.setContent(value, {
emitUpdate: false,
parseOptions: {
preserveWhitespace: true,
},
});
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
@@ -57,6 +57,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails || !src) return;
try {
// @ts-expect-error add proper types for storage
editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName]?.set(src, true);
// update assets list storage value
editor.commands.updateAssetsList?.({
@@ -63,6 +63,7 @@ export const TrackFileRestorationPlugin = (editor: Editor, restoreHandler: TFile
const src = node.attrs.src;
const nodeFileSetDetails = NODE_FILE_MAP[nodeType];
if (!nodeFileSetDetails) return;
// @ts-expect-error add proper types for storage
const extensionFileSetStorage = editor.storage[nodeType]?.[nodeFileSetDetails.fileSetName];
const wasDeleted = extensionFileSetStorage?.get(src);
if (!nodeFileSetDetails || !src) return;
@@ -9,6 +9,7 @@ export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
// @ts-expect-error tiptap-markdown types are not updated
const markdownSerializer = editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
@@ -1,87 +1,10 @@
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
// Base type for all action payloads
export type BaseActionPayload = {
user_id?: string;
};
// Generic type for creating specific payloads
export type CreatePayload<T = Record<string, never>> = BaseActionPayload & T;
export type TDocumentEventEmitter = {
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
};
export type TDocumentEventKey = keyof typeof DocumentCollaborativeEvents;
export type TDocumentEventsClient = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["client"];
export type TDocumentEventsServer = (typeof DocumentCollaborativeEvents)[TDocumentEventKey]["server"];
// In this version, our union of all events (the client names) is:
export type TAllEventTypes = TDocumentEventsClient;
// Create a mapping from each client event to its payload type using key remapping.
export type EventToPayloadMap = {
[K in keyof typeof DocumentCollaborativeEvents as (typeof DocumentCollaborativeEvents)[K]["client"]]: (typeof DocumentCollaborativeEvents)[K]["payloadType"];
};
// Common fields for every realtime event
export type CommonRealtimeFields = {
affectedPages: {
currentPage: string;
parentPage: string | null;
descendantPages: string[];
};
workspace_slug: string;
project_id?: string;
teamspace_id?: string;
user_id: string;
timestamp: string;
};
// Helper function to create a realtime event in a typesafe way.
export function createRealtimeEvent<T extends keyof EventToPayloadMap>(
opts: ApiServerPayload<T>
): CommonRealtimeFields & BroadcastedEvent<T> {
return {
affectedPages: {
currentPage: opts.page_id || "",
parentPage: opts.parent_id || null,
descendantPages: opts.descendants_ids || [],
},
workspace_slug: opts.workspace_slug,
project_id: opts.project_id || "",
teamspace_id: opts.teamspace_id || "",
user_id: opts.user_id,
timestamp: new Date().toISOString(),
action: opts.action,
data: opts.data,
};
}
export type ApiServerPayload<T extends keyof EventToPayloadMap> = {
action: T;
descendants_ids: string[];
page_id?: string;
parent_id?: string;
data: EventToPayloadMap[T];
project_id?: string;
teamspace_id?: string;
workspace_slug: string;
user_id: string;
};
// Create a discriminated union for broadcast payloads.
// For every key in EventToPayloadMap, we make a union member with the common fields.
export type BroadcastPayloadUnion = {
[K in keyof EventToPayloadMap]: ApiServerPayload<K>;
}[keyof EventToPayloadMap];
export type BroadcastedEventUnion = {
[K in keyof EventToPayloadMap]: BroadcastedEvent<K>;
}[keyof EventToPayloadMap];
export type BroadcastedEvent<T extends keyof EventToPayloadMap = keyof EventToPayloadMap> = CommonRealtimeFields & {
action: T;
data: EventToPayloadMap[T];
export type TDocumentEventEmitter = {
on: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
off: (event: string, callback: (message: { payload: TDocumentEventsClient }) => void) => void;
};
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"files": [
"library.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "1.1.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"exports": {
+1 -1
View File
@@ -3,7 +3,7 @@ import { TIssuePriorities } from "./issues";
export type TDuplicateIssuePayload = {
title: string;
workspace_id: string;
issue_id?: string | null;
issue_id?: string;
project_id?: string;
description_stripped?: string;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"files": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "1.1.0",
"version": "1.0.0",
"sideEffects": false,
"license": "AGPL-3.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "1.1.0",
"version": "1.0.0",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,
@@ -27,7 +27,7 @@
"@plane/types": "workspace:*",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "3.2.7",
"isomorphic-dompurify": "^2.16.0",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"react": "catalog:",
+4 -16
View File
@@ -7,14 +7,9 @@ import { getWeekNumberOfDate, renderFormattedPayloadDate } from "./datetime";
* @returns {ICalendarPayload} calendar payload to render the calendar
* @param {ICalendarPayload | null} currentStructure current calendar payload
* @param {Date} startDate date of the month to render
* @param {EStartOfTheWeek} startOfWeek the day to start the week on
* @description Returns calendar payload to render the calendar, if currentStructure is null, it will generate the payload for the month of startDate, else it will construct the payload for the month of startDate and append it to the currentStructure
*/
export const generateCalendarData = (
currentStructure: ICalendarPayload | null,
startDate: Date,
startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY
): ICalendarPayload => {
export const generateCalendarData = (currentStructure: ICalendarPayload | null, startDate: Date): ICalendarPayload => {
const calendarData: ICalendarPayload = currentStructure ?? {};
const startMonth = startDate.getMonth();
@@ -24,15 +19,10 @@ export const generateCalendarData = (
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const totalDaysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfMonthRaw = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
// Adjust firstDayOfMonth based on startOfWeek preference
// This calculates how many empty cells we need at the start of the calendar
const firstDayOfMonth = (firstDayOfMonthRaw - startOfWeek + 7) % 7;
const firstDayOfMonth = new Date(year, month, 1).getDay(); // Sunday is 0, Monday is 1, ..., Saturday is 6
calendarData[`y-${year}`] ||= {};
// Always reset the month data to ensure clean regeneration with correct startOfWeek
calendarData[`y-${year}`][`m-${month}`] = {};
calendarData[`y-${year}`][`m-${month}`] ||= {};
const numWeeks = Math.ceil((totalDaysInMonth + firstDayOfMonth) / 7);
@@ -60,9 +50,7 @@ export const generateCalendarData = (
};
}
// Use sequential week index instead of calculated week number for the key
// This ensures weeks are grouped correctly regardless of startOfWeek preference
calendarData[`y-${year}`][`m-${month}`][`w-${week}`] = currentWeekObject;
calendarData[`y-${year}`][`m-${month}`][`w-${weekNumber}`] = currentWeekObject;
}
return calendarData;
+1 -1
View File
@@ -1,4 +1,4 @@
import DOMPurify from "dompurify";
import DOMPurify from "isomorphic-dompurify";
import type { Content, JSONContent } from "@plane/types";
/**
+818 -552
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -27,10 +27,10 @@ catalog:
"@types/node": 22.12.0
typescript: 5.8.3
tsdown: 0.15.5
vite: 7.1.11
vite: 7.1.7
uuid: 13.0.0
"@tiptap/core": ^2.22.3
"@tiptap/html": ^2.22.3
"@tiptap/core": ^3.5.3
"@tiptap/html": ^3.5.3
onlyBuiltDependencies:
- turbo