ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: причина отклонения, reply и in-app уведомления

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 09:22:15 +03:00
parent 61a8625a5c
commit 0a584abf26
25 changed files with 623 additions and 32 deletions

View File

@ -149,15 +149,16 @@
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта - если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить` - для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур - `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые` - `Отклонить` требует комментарий причины, возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
- source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи - source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи
- вложения доступны через proxy download endpoint без прямого membership в target project - вложения доступны через proxy download endpoint без прямого membership в target project
- detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки - detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки
- инициатор может отправить комментарий обратно во внешний контур прямо из source-side карточки
Что остается: Что остается:
- зеркалирование inline-файлов из комментариев и описания, а не только issue attachments - зеркалирование inline-файлов из комментариев и описания, а не только issue attachments
- двусторонняя работа с комментариями из source-side карточки - realtime вместо polling
- комментарий причины отклонения и ответ обратно во внешний контур - отдельная сущность или шаг для переноса принятого результата во `Внутренний контур`
## Этап 4. Уведомления ## Этап 4. Уведомления
@ -179,6 +180,27 @@
- инициатор получает уведомления по ключевым событиям жизненного цикла внешнего запроса - инициатор получает уведомления по ключевым событиям жизненного цикла внешнего запроса
### Статус
Реализовано частично.
Что уже работает:
- создаются in-app уведомления по изменениям целевой задачи внешнего контура
- покрыты события:
- смена статуса
- новый комментарий
- изменение описания
- новое вложение
- уведомление привязано к source project, а не требует membership в target project
- notification payload несет `external contour request id` и `target issue id`
- список уведомлений помечает такие записи как `is_external_contour = true`
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
Что остается:
- in-app уведомления на явные source-side решения `Принять / Отклонить`
- отдельный индикатор новых изменений в списке `Открытые / Завершенные`
- push/realtime канал вместо обычного цикла обновления UI
## Этап 5. Полировка и правила эксплуатации ## Этап 5. Полировка и правила эксплуатации
### Что входит ### Что входит

View File

@ -56,6 +56,7 @@ from .intake import (
from .external_contours import ( from .external_contours import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer, ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,

View File

@ -29,6 +29,16 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
class ExternalContourRequestDecisionSerializer(serializers.Serializer): class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"]) action = serializers.ChoiceField(choices=["accept", "decline"])
comment = serializers.CharField(required=False, allow_blank=True)
def validate(self, data):
if data.get("action") == "decline" and not (data.get("comment") or "").strip():
raise serializers.ValidationError({"comment": "Decline reason is required"})
return data
class ExternalContourRequestReplySerializer(serializers.Serializer):
comment = serializers.CharField()
class ExternalContourTargetProjectSerializer(BaseSerializer): class ExternalContourTargetProjectSerializer(BaseSerializer):

View File

@ -4,44 +4,51 @@
from django.urls import path from django.urls import path
from plane.api.views import ( from plane.app.views import (
ExternalContourAttachmentDownloadAPIEndpoint, ExternalContourAttachmentDownloadEndpoint,
ExternalContourDetailAPIEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionAPIEndpoint, ExternalContourDecisionEndpoint,
ExternalContourListCreateAPIEndpoint, ExternalContourListCreateEndpoint,
ExternalContourTargetOptionsAPIEndpoint, ExternalContourReplyEndpoint,
ExternalContourTargetProjectListAPIEndpoint, ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
) )
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours", name="external-contours",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]),
name="external-contour-targets", name="external-contour-targets",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/<uuid:target_project_id>/options/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/<uuid:target_project_id>/options/",
ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]),
name="external-contour-target-options", name="external-contour-target-options",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]), ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision", name="external-contour-decision",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
name="external-contour-reply",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]), ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download", name="external-contour-attachment-download",
), ),
] ]

View File

@ -60,6 +60,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint, ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint, ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint, ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourReplyEndpoint as ExternalContourReplyAPIEndpoint,
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint, ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint, ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
) )

View File

@ -5,22 +5,26 @@
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer, ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,
) )
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView 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, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
class ExternalContourListCreateEndpoint(BaseAPIView): class ExternalContourListCreateEndpoint(BaseAPIView):
@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"] action = serializer.validated_data["action"]
comment = (serializer.validated_data.get("comment") or "").strip()
issue = contour_request.issue issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]: if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
if not target_default_state: if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=comment,
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) issue.save(update_fields=["state", "updated_at"])
@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
extra.pop("source_decision", None) extra.pop("source_decision", None)
extra.pop("source_decision_at", None) extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None) extra.pop("source_decision_by_name", None)
extra["last_decline_comment"] = comment
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra contour_request.extra = extra
@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourReplyEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=serializer.validated_data["comment"],
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
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()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView): class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]

View File

@ -15,6 +15,7 @@ class NotificationSerializer(BaseSerializer):
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by") triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
is_inbox_issue = serializers.BooleanField(read_only=True) is_inbox_issue = serializers.BooleanField(read_only=True)
is_intake_issue = serializers.BooleanField(read_only=True) is_intake_issue = serializers.BooleanField(read_only=True)
is_external_contour = serializers.BooleanField(read_only=True)
is_mentioned_notification = serializers.BooleanField(read_only=True) is_mentioned_notification = serializers.BooleanField(read_only=True)
class Meta: class Meta:

View File

@ -9,6 +9,7 @@ from plane.app.views import (
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint, ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetOptionsEndpoint, ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint, ExternalContourTargetProjectListEndpoint,
) )
@ -40,6 +41,11 @@ urlpatterns = [
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]), ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision", name="external-contour-decision",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
name="external-contour-reply",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]), ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),

View File

@ -229,6 +229,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint, ExternalContourDecisionEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetProjectListEndpoint, ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint, ExternalContourTargetOptionsEndpoint,
) )

View File

@ -5,12 +5,15 @@
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer, ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer, ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer, ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer, ExternalContourTargetProjectSerializer,
@ -21,6 +24,7 @@ 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, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
class ExternalContourListCreateEndpoint(BaseAPIView): class ExternalContourListCreateEndpoint(BaseAPIView):
@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"] action = serializer.validated_data["action"]
comment = (serializer.validated_data.get("comment") or "").strip()
issue = contour_request.issue issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]: if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
if not target_default_state: if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=comment,
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) issue.save(update_fields=["state", "updated_at"])
@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
extra.pop("source_decision", None) extra.pop("source_decision", None)
extra.pop("source_decision_at", None) extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None) extra.pop("source_decision_by_name", None)
extra["last_decline_comment"] = comment
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra contour_request.extra = extra
@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourReplyEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=serializer.validated_data["comment"],
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
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()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView): class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]

View File

@ -65,6 +65,13 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.filter(entity_name="issue") .filter(entity_name="issue")
.annotate(is_inbox_issue=Exists(intake_issue)) .annotate(is_inbox_issue=Exists(intake_issue))
.annotate(is_intake_issue=Exists(intake_issue)) .annotate(is_intake_issue=Exists(intake_issue))
.annotate(
is_external_contour=Case(
When(sender__startswith="in_app:external_contours:", then=True),
default=False,
output_field=BooleanField(),
)
)
.annotate( .annotate(
is_mentioned_notification=Case( is_mentioned_notification=Case(
When(sender__icontains="mentioned", then=True), When(sender__icontains="mentioned", then=True),

View File

@ -23,8 +23,10 @@ from plane.db.models import (
IssueActivity, IssueActivity,
UserNotificationPreference, UserNotificationPreference,
ProjectMember, ProjectMember,
IntakeIssue,
) )
from django.db.models import Subquery from django.db.models import Subquery
from plane.utils.external_contours import create_external_contour_notification
# Third Party imports # Third Party imports
from celery import shared_task from celery import shared_task
@ -99,6 +101,10 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists() and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
): ):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
external_contour_request = IntakeIssue.objects.filter(
issue_id=issue_id,
extra__bridge="external-contours",
).first()
bulk_mention_subscribers.append( bulk_mention_subscribers.append(
IssueSubscriber( IssueSubscriber(
@ -288,6 +294,10 @@ def notifications(
) )
issue = Issue.objects.filter(pk=issue_id).first() issue = Issue.objects.filter(pk=issue_id).first()
external_contour_request = IntakeIssue.objects.filter(
issue_id=issue_id,
extra__bridge="external-contours",
).first()
if subscriber: if subscriber:
# add the user to issue subscriber # add the user to issue subscriber
@ -517,7 +527,19 @@ def notifications(
}, },
) )
) )
bulk_notifications.append(notification)
if external_contour_request is not None:
for issue_activity in issue_activities_created:
if issue_activity.get("issue_detail").get("id") != issue_id:
continue
external_contour_notification = create_external_contour_notification(
contour_request=external_contour_request,
issue=issue,
issue_activity_data=issue_activity,
)
if external_contour_notification is not None:
bulk_notifications.append(external_contour_notification)
for mention_id in new_mentions: for mention_id in new_mentions:
if mention_id != actor_id: if mention_id != actor_id:

View File

@ -0,0 +1,123 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.utils.html import escape
from plane.app.serializers import IssueCommentSerializer
from plane.db.models import IssueComment, Notification, Project
EXTERNAL_CONTOUR_NOTIFICATION_FIELDS = {"state", "comment", "attachment", "description"}
def build_external_contour_comment_html(comment: str) -> str:
escaped_comment = escape((comment or "").strip())
escaped_comment = escaped_comment.replace("\n", "<br />")
return f"<p>{escaped_comment}</p>"
def create_external_contour_issue_comment(*, issue, actor, comment: str, origin: str):
from plane.bgtasks.issue_activities_task import issue_activity
comment_html = build_external_contour_comment_html(comment)
serializer = IssueCommentSerializer(data={"comment_html": comment_html})
serializer.is_valid(raise_exception=True)
serializer.save(project_id=issue.project_id, issue_id=issue.id, actor=actor)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(actor.id),
issue_id=str(issue.id),
project_id=str(issue.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=origin,
)
return serializer.data
def create_external_contour_notification(*, contour_request, issue, issue_activity_data):
extra = contour_request.extra or {}
receiver_id = extra.get("requested_by_id")
source_project_id = extra.get("source_project_id")
field = issue_activity_data.get("field")
actor_id = issue_activity_data.get("actor_id") or (issue_activity_data.get("actor_detail") or {}).get("id")
if not receiver_id or not source_project_id or not field:
return None
if field not in EXTERNAL_CONTOUR_NOTIFICATION_FIELDS:
return None
if str(receiver_id) == str(actor_id):
return None
source_project = Project.objects.filter(
pk=source_project_id,
workspace_id=issue.workspace_id,
archived_at__isnull=True,
).first()
if not source_project:
return None
issue_comment = (
IssueComment.objects.filter(
id=issue_activity_data.get("issue_comment"),
issue_id=issue.id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
).first()
if issue_activity_data.get("issue_comment")
else None
)
return Notification(
workspace=source_project.workspace,
project=source_project,
sender=f"in_app:external_contours:{field}",
triggered_by_id=actor_id,
receiver_id=receiver_id,
entity_identifier=issue.id,
entity_name="issue",
title=issue_activity_data.get("comment") or "External contour updated",
data={
"issue": {
"id": str(contour_request.id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name if issue.state else None,
"state_group": issue.state.group if issue.state else None,
"project_id": str(source_project.id),
"workspace_slug": str(source_project.workspace.slug),
"external_contour_request_id": str(contour_request.id),
"target_issue_id": str(issue.id),
"target_project_id": str(issue.project_id),
"target_project_name": str(issue.project.name),
"source_project_id": str(source_project.id),
"source_project_name": str(source_project.name),
},
"issue_activity": {
"id": str(issue_activity_data.get("id")),
"verb": str(issue_activity_data.get("verb")),
"field": str(field),
"actor": str(actor_id),
"new_value": str(issue_activity_data.get("new_value")),
"old_value": str(issue_activity_data.get("old_value")),
"issue_comment": str(issue_comment.comment_stripped if issue_comment is not None else ""),
"old_identifier": (
str(issue_activity_data.get("old_identifier")) if issue_activity_data.get("old_identifier") else None
),
"new_identifier": (
str(issue_activity_data.get("new_identifier")) if issue_activity_data.get("new_identifier") else None
),
},
},
)

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { EModalPosition, EModalWidth, ModalCore, TextArea } from "@plane/ui";
type Props = {
isOpen: boolean;
isSubmitting?: boolean;
onClose: () => void;
onSubmit: (comment: string) => Promise<void>;
};
export function ExternalContourDeclineModal(props: Props) {
const { isOpen, isSubmitting = false, onClose, onSubmit } = props;
const { t } = useTranslation();
const [comment, setComment] = useState("");
const handleClose = () => {
if (isSubmitting) return;
setComment("");
onClose();
};
const handleSubmit = async () => {
if (!comment.trim() || isSubmitting) return;
await onSubmit(comment.trim());
setComment("");
};
return (
<ModalCore
isOpen={isOpen}
handleClose={handleClose}
position={EModalPosition.CENTER}
width={EModalWidth.LG}
className="rounded-lg"
>
<div className="space-y-4 p-6">
<div className="space-y-1">
<h3 className="text-18 font-semibold text-primary">{t("external_contours_page.decline_modal.title")}</h3>
<p className="text-13 text-secondary">{t("external_contours_page.decline_modal.description")}</p>
</div>
<TextArea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={t("external_contours_page.decline_modal.placeholder")}
rows={5}
disabled={isSubmitting}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={handleClose} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button variant="primary" onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
{t("external_contours_page.decline_modal.submit")}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react"; import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
@ -22,6 +22,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContourStatePill } from "./state-pill"; import { ExternalContourStatePill } from "./state-pill";
import { ExternalContourDeclineModal } from "./decline-modal";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -45,7 +46,8 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const router = useAppRouter(); const router = useAppRouter();
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours(); const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const issue = contourRequest.issue; const issue = contourRequest.issue;
@ -96,10 +98,11 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}) })
); );
const handleDecision = async (action: "accept" | "decline") => { const handleDecision = async (action: "accept" | "decline", comment?: string) => {
try { try {
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action); await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
if (action === "decline") { if (action === "decline") {
setIsDeclineModalOpen(false);
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN); await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
router.push( router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}` `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
@ -125,6 +128,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
return ( return (
<> <>
<ExternalContourDeclineModal
isOpen={isDeclineModalOpen}
isSubmitting={loader === "mutation-loading"}
onClose={() => setIsDeclineModalOpen(false)}
onSubmit={(comment) => handleDecision("decline", comment)}
/>
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex"> <Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{issue?.project_id && issue.sequence_id && ( {issue?.project_id && issue.sequence_id && (
@ -151,7 +161,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" /> <CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")} {t("external_contours_page.actions.accept")}
</Button> </Button>
<Button variant="secondary" size="lg" onClick={() => handleDecision("decline")}> <Button variant="secondary" size="lg" onClick={() => setIsDeclineModalOpen(true)}>
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" /> <CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")} {t("external_contours_page.actions.decline")}
</Button> </Button>
@ -189,7 +199,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}> <Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}>
{t("external_contours_page.actions.accept")} {t("external_contours_page.actions.accept")}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => handleDecision("decline")}> <Button variant="secondary" size="sm" onClick={() => setIsDeclineModalOpen(true)}>
{t("external_contours_page.actions.decline")} {t("external_contours_page.actions.decline")}
</Button> </Button>
</> </>

View File

@ -34,6 +34,7 @@ import { ExternalContoursMirroredAttachments } from "./mirrored-attachments";
import { ExternalContoursMirroredComments } from "./mirrored-comments"; import { ExternalContoursMirroredComments } from "./mirrored-comments";
import { ExternalContoursIssueContentProperties } from "./issue-properties"; import { ExternalContoursIssueContentProperties } from "./issue-properties";
import { ExternalContoursRequestTraceability } from "./request-traceability"; import { ExternalContoursRequestTraceability } from "./request-traceability";
import { ExternalContoursSourceReplyBox } from "./source-reply-box";
const workItemVersionService = new WorkItemVersionService(); const workItemVersionService = new WorkItemVersionService();
const issueService = new IssueService(); const issueService = new IssueService();
@ -172,6 +173,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<ExternalContoursMirroredAttachments attachments={mirroredAttachments} /> <ExternalContoursMirroredAttachments attachments={mirroredAttachments} />
<ExternalContoursSourceReplyBox
workspaceSlug={workspaceSlug}
sourceProjectId={sourceProjectId}
requestId={contourRequest.id}
/>
<ExternalContoursMirroredComments comments={mirroredComments} /> <ExternalContoursMirroredComments comments={mirroredComments} />
<ExternalContoursMirroredActivity activity={mirroredActivity} /> <ExternalContoursMirroredActivity activity={mirroredActivity} />

View File

@ -0,0 +1,66 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { TextArea } from "@plane/ui";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
type Props = {
workspaceSlug: string;
sourceProjectId: string;
requestId: string;
};
export const ExternalContoursSourceReplyBox = observer(function ExternalContoursSourceReplyBox(props: Props) {
const { workspaceSlug, sourceProjectId, requestId } = props;
const { t } = useTranslation();
const { loader, replyToRequest } = useProjectExternalContours();
const [comment, setComment] = useState("");
const isSubmitting = loader === "mutation-loading";
const handleSubmit = async () => {
if (!comment.trim() || isSubmitting) return;
try {
await replyToRequest(workspaceSlug, sourceProjectId, requestId, comment.trim());
setComment("");
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("success"),
message: t("external_contours_page.reply.success"),
});
} catch (error: any) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: error?.error || t("external_contours_page.reply.error"),
});
}
};
return (
<div className="space-y-3 py-4">
<div className="text-body-sm-medium">{t("external_contours_page.reply.title")}</div>
<TextArea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder={t("external_contours_page.reply.placeholder")}
disabled={isSubmitting}
/>
<div className="flex justify-end">
<Button variant="primary" onClick={handleSubmit} disabled={isSubmitting || !comment.trim()}>
{t("external_contours_page.reply.submit")}
</Button>
</div>
</div>
);
});

View File

@ -20,6 +20,7 @@ import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties"; import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
// plane web imports // plane web imports
import { useNotificationPreview } from "@/plane-web/hooks/use-notification-preview"; import { useNotificationPreview } from "@/plane-web/hooks/use-notification-preview";
import { ExternalContoursContentRoot } from "@/plane-web/components/projects/external-contours/content-root";
// local imports // local imports
import { InboxContentRoot } from "../inbox/content"; import { InboxContentRoot } from "../inbox/content";
@ -40,7 +41,7 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
const { fetchUserProjectInfo } = useUserPermissions(); const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview(); const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
// derived values // derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue } = const { workspace_slug, project_id, issue_id, is_inbox_issue, is_external_contour } =
notificationLiteByNotificationId(currentSelectedNotificationId); notificationLiteByNotificationId(currentSelectedNotificationId);
// fetching workspace work item properties // fetching workspace work item properties
@ -64,10 +65,12 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
// fetching user project member info // fetching user project member info
const { isLoading: projectMemberInfoLoader } = useSWR( const { isLoading: projectMemberInfoLoader } = useSWR(
workspace_slug && project_id && is_inbox_issue workspace_slug && project_id && (is_inbox_issue || is_external_contour)
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}` ? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
: null, : null,
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null workspace_slug && project_id && (is_inbox_issue || is_external_contour)
? () => fetchUserProjectInfo(workspace_slug, project_id)
: null
); );
const embedRemoveCurrentNotification = useCallback( const embedRemoveCurrentNotification = useCallback(
@ -91,7 +94,23 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
</div> </div>
) : ( ) : (
<> <>
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? ( {is_external_contour === true && workspace_slug && project_id && issue_id ? (
<>
{projectMemberInfoLoader ? (
<div className="flex h-full w-full items-center justify-center">
<LogoSpinner />
</div>
) : (
<ExternalContoursContentRoot
setIsMobileSidebar={() => {}}
isMobileSidebar={false}
workspaceSlug={workspace_slug}
projectId={project_id}
inboxIssueId={issue_id}
/>
)}
</>
) : is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
<> <>
{projectMemberInfoLoader ? ( {projectMemberInfoLoader ? (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">

View File

@ -81,10 +81,27 @@ export class ExternalContourService extends APIService {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: string, requestId: string,
action: "accept" | "decline" action: "accept" | "decline",
comment?: string
): Promise<TExternalContourRequest> { ): Promise<TExternalContourRequest> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, {
action, action,
comment,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async reply(
workspaceSlug: string,
projectId: string,
requestId: string,
comment: string
): Promise<TExternalContourRequest> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/reply/`, {
comment,
}) })
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {

View File

@ -38,7 +38,14 @@ export interface IProjectExternalContoursStore {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: string, requestId: string,
action: "accept" | "decline" action: "accept" | "decline",
comment?: string
) => Promise<TExternalContourRequest | undefined>;
replyToRequest: (
workspaceSlug: string,
projectId: string,
requestId: string,
comment: string
) => Promise<TExternalContourRequest | undefined>; ) => Promise<TExternalContourRequest | undefined>;
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
@ -89,6 +96,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById: action, fetchRequestById: action,
createRequest: action, createRequest: action,
decideRequest: action, decideRequest: action,
replyToRequest: action,
handleCurrentTab: action, handleCurrentTab: action,
upsertRequests: action, upsertRequests: action,
updateRequestIssue: action, updateRequestIssue: action,
@ -231,11 +239,29 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: string, requestId: string,
action: "accept" | "decline" action: "accept" | "decline",
comment?: string
) => { ) => {
this.loader = "mutation-loading"; this.loader = "mutation-loading";
try { try {
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action); const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action, comment);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;
});
return request;
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
throw error;
}
};
replyToRequest = async (workspaceSlug: string, projectId: string, requestId: string, comment: string) => {
this.loader = "mutation-loading";
try {
const request = await this.externalContourService.reply(workspaceSlug, projectId, requestId, comment);
runInAction(() => { runInAction(() => {
this.upsertRequests([request]); this.upsertRequests([request]);
this.loader = undefined; this.loader = undefined;

View File

@ -48,6 +48,7 @@ export class Notification implements INotification {
archived_at: string | undefined = undefined; archived_at: string | undefined = undefined;
snoozed_till: string | undefined = undefined; snoozed_till: string | undefined = undefined;
is_inbox_issue: boolean | undefined = undefined; is_inbox_issue: boolean | undefined = undefined;
is_external_contour: boolean | undefined = undefined;
is_mentioned_notification: boolean | undefined = undefined; is_mentioned_notification: boolean | undefined = undefined;
workspace: string | undefined = undefined; workspace: string | undefined = undefined;
project: string | undefined = undefined; project: string | undefined = undefined;
@ -79,6 +80,7 @@ export class Notification implements INotification {
archived_at: observable.ref, archived_at: observable.ref,
snoozed_till: observable.ref, snoozed_till: observable.ref,
is_inbox_issue: observable.ref, is_inbox_issue: observable.ref,
is_external_contour: observable.ref,
is_mentioned_notification: observable.ref, is_mentioned_notification: observable.ref,
workspace: observable.ref, workspace: observable.ref,
project: observable.ref, project: observable.ref,
@ -112,6 +114,7 @@ export class Notification implements INotification {
this.archived_at = this.notification.archived_at; this.archived_at = this.notification.archived_at;
this.snoozed_till = this.notification.snoozed_till; this.snoozed_till = this.notification.snoozed_till;
this.is_inbox_issue = this.notification.is_inbox_issue; this.is_inbox_issue = this.notification.is_inbox_issue;
this.is_external_contour = this.notification.is_external_contour;
this.is_mentioned_notification = this.notification.is_mentioned_notification; this.is_mentioned_notification = this.notification.is_mentioned_notification;
this.workspace = this.notification.workspace; this.workspace = this.notification.workspace;
this.project = this.notification.project; this.project = this.notification.project;
@ -143,6 +146,7 @@ export class Notification implements INotification {
archived_at: this.archived_at, archived_at: this.archived_at,
snoozed_till: this.snoozed_till, snoozed_till: this.snoozed_till,
is_inbox_issue: this.is_inbox_issue, is_inbox_issue: this.is_inbox_issue,
is_external_contour: this.is_external_contour,
is_mentioned_notification: this.is_mentioned_notification, is_mentioned_notification: this.is_mentioned_notification,
workspace: this.workspace, workspace: this.workspace,
project: this.project, project: this.project,

View File

@ -169,6 +169,7 @@ export class WorkspaceNotificationStore implements IWorkspaceNotificationStore {
notification_id: notification.id, notification_id: notification.id,
issue_id: notification.data?.issue?.id, issue_id: notification.data?.issue?.id,
is_inbox_issue: notification.is_inbox_issue || false, is_inbox_issue: notification.is_inbox_issue || false,
is_external_contour: notification.is_external_contour || false,
}; };
}); });

View File

@ -389,6 +389,19 @@ export default {
unsupported_message: unsupported_message:
"The “{action}” button is already placed in the right UI slot. Real routing and the reverse flow will be connected next.", "The “{action}” button is already placed in the right UI slot. Real routing and the reverse flow will be connected next.",
}, },
decline_modal: {
title: "Return the request for rework",
description: "Provide the reason for returning the request. This comment will be sent to the external contour and added to the target issue.",
placeholder: "Describe what needs to be revised or clarified",
submit: "Decline and return",
},
reply: {
title: "Reply to the external contour",
placeholder: "Write a comment that will be added to the target issue",
submit: "Send comment",
success: "The comment has been sent to the external contour.",
error: "The comment could not be sent to the external contour.",
},
}, },
deactivate_your_account: "Deactivate your account", deactivate_your_account: "Deactivate your account",
deactivate_your_account_description: deactivate_your_account_description:

View File

@ -545,6 +545,19 @@ export default {
unsupported_message: unsupported_message:
"Кнопка «{action}» уже стоит на правильном месте в UI. Реальную маршрутизацию и обратный поток подключим следующим этапом.", "Кнопка «{action}» уже стоит на правильном месте в UI. Реальную маршрутизацию и обратный поток подключим следующим этапом.",
}, },
decline_modal: {
title: "Вернуть запрос на доработку",
description: "Укажите причину возврата. Этот комментарий уйдёт во внешний контур и появится в целевой задаче.",
placeholder: "Напишите, что нужно доработать или уточнить",
submit: "Отклонить и вернуть",
},
reply: {
title: "Ответ во внешний контур",
placeholder: "Напишите комментарий, который уйдёт в целевую задачу",
submit: "Отправить комментарий",
success: "Комментарий отправлен во внешний контур.",
error: "Не удалось отправить комментарий во внешний контур.",
},
}, },
deactivate_your_account: "Деактивировать ваш аккаунт", deactivate_your_account: "Деактивировать ваш аккаунт",
deactivate_your_account_description: deactivate_your_account_description:

View File

@ -25,6 +25,12 @@ export type TNotificationIssueLite = {
name: string | undefined; name: string | undefined;
state_name: string | undefined; state_name: string | undefined;
state_group: string | undefined; state_group: string | undefined;
external_contour_request_id?: string | undefined;
target_issue_id?: string | undefined;
source_project_id?: string | undefined;
source_project_name?: string | undefined;
target_project_id?: string | undefined;
target_project_name?: string | undefined;
}; };
export type TNotificationData = { export type TNotificationData = {
@ -57,6 +63,7 @@ export type TNotification = {
archived_at: string | undefined; archived_at: string | undefined;
snoozed_till: string | undefined; snoozed_till: string | undefined;
is_inbox_issue: boolean | undefined; is_inbox_issue: boolean | undefined;
is_external_contour: boolean | undefined;
is_mentioned_notification: boolean | undefined; is_mentioned_notification: boolean | undefined;
workspace: string | undefined; workspace: string | undefined;
project: string | undefined; project: string | undefined;
@ -103,4 +110,5 @@ export type TNotificationLite = {
notification_id: string | undefined; notification_id: string | undefined;
issue_id: string | undefined; issue_id: string | undefined;
is_inbox_issue: boolean | undefined; is_inbox_issue: boolean | undefined;
is_external_contour: boolean | undefined;
}; };