diff --git a/docs_prod/cross-project-task-routing/phase-roadmap.md b/docs_prod/cross-project-task-routing/phase-roadmap.md
index 57f7565..302aedb 100644
--- a/docs_prod/cross-project-task-routing/phase-roadmap.md
+++ b/docs_prod/cross-project-task-routing/phase-roadmap.md
@@ -99,10 +99,10 @@
- отображение целевого проекта
- отображение исполнителей целевого контура
- отображение фактической даты последнего изменения
+- индикатор новых изменений в source-side списке на базе unread уведомлений
- открытие source-side detail экрана
Что еще остается на следующие этапы:
-- индикатор новых изменений
- полноценная зеркальная activity/history
- уведомления
@@ -195,6 +195,7 @@
- notification payload несет `external contour request id` и `target issue id`
- список уведомлений помечает такие записи как `is_external_contour = true`
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
+- открытие source-side карточки помечает связанные unread уведомления как прочитанные и снимает индикатор новых изменений в списке
Что остается:
- in-app уведомления на явные source-side решения `Принять / Отклонить`
diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py
index 2e0094f..ff7eab7 100644
--- a/plane-src/apps/api/plane/api/serializers/external_contours.py
+++ b/plane-src/apps/api/plane/api/serializers/external_contours.py
@@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.app.serializers.issue import LabelSerializer
-from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
+from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@@ -149,6 +149,7 @@ class ExternalContourMirroredActivitySerializer(BaseSerializer):
class ExternalContourRequestSerializer(BaseSerializer):
+ has_unread_updates = serializers.SerializerMethodField()
issue = ExternalContourIssueSerializer(read_only=True)
mirrored_activity = serializers.SerializerMethodField()
mirrored_attachments = serializers.SerializerMethodField()
@@ -172,6 +173,7 @@ class ExternalContourRequestSerializer(BaseSerializer):
"created_at",
"updated_at",
"created_by",
+ "has_unread_updates",
"issue",
"mirrored_activity",
"mirrored_attachments",
@@ -193,6 +195,20 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_id(self, obj):
return obj.extra.get("source_project_id")
+ def get_has_unread_updates(self, obj):
+ request = self.context.get("request")
+ user = getattr(request, "user", None)
+ user_id = getattr(user, "id", None)
+ if not user_id:
+ return False
+
+ return Notification.objects.filter(
+ receiver_id=user_id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(obj.id),
+ ).exists()
+
def get_mirrored_activity(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
diff --git a/plane-src/apps/api/plane/api/views/external_contours.py b/plane-src/apps/api/plane/api/views/external_contours.py
index 300c1d9..1e985b1 100644
--- a/plane-src/apps/api/plane/api/views/external_contours.py
+++ b/plane-src/apps/api/plane/api/views/external_contours.py
@@ -21,7 +21,7 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView
-from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
+from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
@@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id):
- serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
+ serializer = ExternalContourRequestSerializer(
+ self.get_queryset(),
+ many=True,
+ context={"request": request},
+ )
return Response(
{
"results": serializer.data,
@@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
- .get(pk=intake_issue.id)
+ .get(pk=intake_issue.id),
+ context={"request": request},
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
+ def mark_request_notifications_read(self, user, contour_request):
+ user_id = getattr(user, "id", None)
+ if not user_id:
+ return
+
+ Notification.objects.filter(
+ receiver_id=user_id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
+
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
+ self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
+ Notification.objects.filter(
+ receiver_id=request.user.id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
@@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
+ Notification.objects.filter(
+ receiver_id=request.user.id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py
index 300c1d9..1e985b1 100644
--- a/plane-src/apps/api/plane/app/views/external_contours.py
+++ b/plane-src/apps/api/plane/app/views/external_contours.py
@@ -21,7 +21,7 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView
-from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
+from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
@@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id):
- serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
+ serializer = ExternalContourRequestSerializer(
+ self.get_queryset(),
+ many=True,
+ context={"request": request},
+ )
return Response(
{
"results": serializer.data,
@@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
- .get(pk=intake_issue.id)
+ .get(pk=intake_issue.id),
+ context={"request": request},
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
+ def mark_request_notifications_read(self, user, contour_request):
+ user_id = getattr(user, "id", None)
+ if not user_id:
+ return
+
+ Notification.objects.filter(
+ receiver_id=user_id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
+
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
+ self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
+ Notification.objects.filter(
+ receiver_id=request.user.id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
@@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
+ Notification.objects.filter(
+ receiver_id=request.user.id,
+ sender__startswith="in_app:external_contours:",
+ read_at__isnull=True,
+ data__issue__id=str(contour_request.id),
+ ).update(read_at=timezone.now())
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
+ "request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx
index bef9b6d..6cc7e5a 100644
--- a/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx
+++ b/plane-src/apps/web/ce/components/projects/external-contours/list-item.tsx
@@ -65,6 +65,11 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
{issue.project_detail?.name && {issue.project_detail.name}}
+ {request.has_unread_updates && (
+
+
+
+ )}
diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts
index d8be6dd..82bf78a 100644
--- a/plane-src/packages/i18n/src/locales/en/translations.ts
+++ b/plane-src/packages/i18n/src/locales/en/translations.ts
@@ -296,10 +296,11 @@ export default {
open: "Open",
closed: "Closed",
},
- list: {
- last_updated: "Last updated",
- unassigned: "Unassigned",
- },
+ list: {
+ last_updated: "Last updated",
+ unassigned: "Unassigned",
+ unread_updates: "New updates available",
+ },
empty_state: {
title: "External contours module is ready for the next stage",
description:
diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts
index 04030bb..107a104 100644
--- a/plane-src/packages/i18n/src/locales/ru/translations.ts
+++ b/plane-src/packages/i18n/src/locales/ru/translations.ts
@@ -453,10 +453,11 @@ export default {
open: "Открытые",
closed: "Закрытые",
},
- list: {
- last_updated: "Последнее изменение",
- unassigned: "Не назначено",
- },
+ list: {
+ last_updated: "Последнее изменение",
+ unassigned: "Не назначено",
+ unread_updates: "Есть новые изменения",
+ },
empty_state: {
title: "Модуль внешних контуров подготовлен",
description:
diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts
index 1a27faf..7e01f3a 100644
--- a/plane-src/packages/types/src/external-contours.ts
+++ b/plane-src/packages/types/src/external-contours.ts
@@ -56,6 +56,7 @@ export type TExternalContourMirroredActivity = {
export type TExternalContourRequest = {
created_at: string;
created_by: string | null;
+ has_unread_updates?: boolean;
id: string;
issue: TExternalContourIssue;
mirrored_activity?: TExternalContourMirroredActivity[];