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[];