ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зеркалирование комментариев, вложений и активности внешнего контура

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 08:53:41 +03:00
parent 8195c3fc80
commit 61a8625a5c
16 changed files with 525 additions and 13 deletions

View File

@ -150,11 +150,13 @@
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
- source-side readonly карточка зеркалит актуальные комментарии, вложения и activity целевой задачи
- вложения доступны через proxy download endpoint без прямого membership в target project
- detail карточка source-only пользователя обновляется polling-ом и подтягивает новые комментарии без ручной перезагрузки
Что остается:
- зеркалирование комментариев
- зеркалирование файлов
- зеркалирование activity stream и обновлений описания
- зеркалирование inline-файлов из комментариев и описания, а не только issue attachments
- двусторонняя работа с комментариями из source-side карточки
- комментарий причины отклонения и ответ обратно во внешний контур
## Этап 4. Уведомления

View File

@ -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 IntakeIssue, Issue, Label, Project
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@ -95,8 +95,54 @@ class ExternalContourIssueSerializer(BaseSerializer):
]
class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
uploaded_by = serializers.SerializerMethodField()
download_url = serializers.SerializerMethodField()
class Meta:
model = FileAsset
fields = ["id", "attributes", "asset_url", "download_url", "updated_at", "uploaded_by"]
read_only_fields = fields
def get_uploaded_by(self, obj):
user = obj.updated_by or obj.created_by
return getattr(user, "display_name", None)
def get_download_url(self, obj):
workspace_slug = self.context.get("workspace_slug")
source_project_id = self.context.get("source_project_id")
request_id = self.context.get("request_id")
if not workspace_slug or not source_project_id or not request_id:
return None
return (
f"/api/workspaces/{workspace_slug}/projects/{source_project_id}/external-contours/"
f"{request_id}/attachments/{obj.id}/"
)
class ExternalContourMirroredCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueComment
fields = ["id", "comment_html", "created_at", "updated_at", "edited_at", "parent_id", "actor_detail"]
read_only_fields = fields
class ExternalContourMirroredActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueActivity
fields = ["id", "verb", "field", "old_value", "new_value", "comment", "created_at", "actor_detail"]
read_only_fields = fields
class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True)
mirrored_activity = serializers.SerializerMethodField()
mirrored_attachments = serializers.SerializerMethodField()
mirrored_comments = serializers.SerializerMethodField()
source_project_id = serializers.SerializerMethodField()
source_project_name = serializers.SerializerMethodField()
source_decision = serializers.SerializerMethodField()
@ -117,6 +163,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
"updated_at",
"created_by",
"issue",
"mirrored_activity",
"mirrored_attachments",
"mirrored_comments",
"source_project_id",
"source_project_name",
"source_decision",
@ -134,6 +183,53 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_id(self, obj):
return obj.extra.get("source_project_id")
def get_mirrored_activity(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
activity = (
IssueActivity.objects.filter(issue_id=obj.issue_id)
.exclude(field__in=["comment", "vote", "reaction", "draft"])
.select_related("actor")
.order_by("-created_at")[:50]
)
return ExternalContourMirroredActivitySerializer(activity, many=True).data
def get_mirrored_attachments(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
attachments = (
FileAsset.objects.filter(
issue_id=obj.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
.select_related("created_by", "updated_by")
.order_by("-updated_at")
)
return ExternalContourMirroredAttachmentSerializer(
attachments,
many=True,
context={
"workspace_slug": self.context.get("workspace_slug"),
"source_project_id": self.context.get("source_project_id"),
"request_id": str(obj.id),
},
).data
def get_mirrored_comments(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
comments = (
IssueComment.objects.filter(issue_id=obj.issue_id)
.select_related("actor")
.order_by("created_at")
)
return ExternalContourMirroredCommentSerializer(comments, many=True).data
def get_source_project_name(self, obj):
return obj.extra.get("source_project_name")

View File

@ -5,6 +5,7 @@
from django.urls import path
from plane.api.views import (
ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
@ -38,4 +39,9 @@ urlpatterns = [
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]

View File

@ -56,6 +56,7 @@ from .intake import (
IntakeIssueDetailAPIEndpoint,
)
from .external_contours import (
ExternalContourAttachmentDownloadEndpoint as ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@ -17,8 +18,9 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView
from plane.db.models import 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.settings.storage import S3Storage
class ExternalContourListCreateEndpoint(BaseAPIView):
@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
.get(pk=contour_request.id),
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
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__project", "workspace")
def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset())
attachment = get_object_or_404(
FileAsset,
pk=attachment_id,
issue_id=contour_request.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=attachment.asset.name,
disposition="attachment",
filename=attachment.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)

View File

@ -5,6 +5,7 @@
from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@ -39,4 +40,9 @@ urlpatterns = [
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]

View File

@ -225,6 +225,7 @@ from .notification.base import (
from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@ -17,8 +18,9 @@ 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 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.settings.storage import S3Storage
class ExternalContourListCreateEndpoint(BaseAPIView):
@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
.get(pk=contour_request.id),
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
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__project", "workspace")
def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset())
attachment = get_object_or_404(
FileAsset,
pk=attachment_id,
issue_id=contour_request.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=attachment.asset.name,
disposition="attachment",
filename=attachment.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)

View File

@ -52,7 +52,11 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null,
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
{ revalidateOnFocus: false, revalidateIfStale: false }
{
revalidateOnFocus: !hasDirectTargetAccess,
revalidateIfStale: !hasDirectTargetAccess,
refreshInterval: hasDirectTargetAccess ? 0 : 15000,
}
);
const isEditable =

View File

@ -29,6 +29,9 @@ import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
import { IssueService } from "@/services/issue/issue.service";
import { WorkItemVersionService } from "@/services/issue/work_item_version.service";
import { ExternalContoursMirroredActivity } from "./mirrored-activity";
import { ExternalContoursMirroredAttachments } from "./mirrored-attachments";
import { ExternalContoursMirroredComments } from "./mirrored-comments";
import { ExternalContoursIssueContentProperties } from "./issue-properties";
import { ExternalContoursRequestTraceability } from "./request-traceability";
@ -63,6 +66,9 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
}, [isSubmitting, setIsSubmitting, setShowAlert]);
const issue = contourRequest.issue;
const mirroredActivity = contourRequest.mirrored_activity ?? [];
const mirroredAttachments = contourRequest.mirrored_attachments ?? [];
const mirroredComments = contourRequest.mirrored_comments ?? [];
const targetProjectId = issue.project_id || sourceProjectId;
const { duplicateIssues } = useDebouncedDuplicateIssues(
@ -164,6 +170,12 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
</div>
</div>
<ExternalContoursMirroredAttachments attachments={mirroredAttachments} />
<ExternalContoursMirroredComments comments={mirroredComments} />
<ExternalContoursMirroredActivity activity={mirroredActivity} />
<div className="py-4 text-13 text-secondary">{t("external_contours_page.readonly_source_view")}</div>
</>
);

View File

@ -0,0 +1,87 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TExternalContourMirroredActivity } from "@plane/types";
import { renderFormattedDate } from "@plane/utils";
const FIELD_TRANSLATIONS: Record<string, string> = {
name: "external_contours_page.mirror.fields.name",
description_html: "external_contours_page.mirror.fields.description",
state: "external_contours_page.mirror.fields.state",
priority: "external_contours_page.mirror.fields.priority",
target_date: "external_contours_page.mirror.fields.due_date",
assignees: "external_contours_page.mirror.fields.assignees",
labels: "external_contours_page.mirror.fields.labels",
};
type Props = {
activity: TExternalContourMirroredActivity[];
};
export const ExternalContoursMirroredActivity = observer(function ExternalContoursMirroredActivity(props: Props) {
const { activity } = props;
const { t } = useTranslation();
const getFieldLabel = (field?: string | null) => {
if (!field) return t("external_contours_page.mirror.fields.request");
const key = FIELD_TRANSLATIONS[field];
return key ? t(key) : field;
};
const renderMessage = (item: TExternalContourMirroredActivity) => {
const actorName = item.actor_detail?.display_name || t("external_contours_page.mirror.system_actor");
const fieldLabel = getFieldLabel(item.field);
if (item.verb === "created") {
return t("external_contours_page.mirror.activity_created", { actor: actorName });
}
if (item.old_value && item.new_value) {
return t("external_contours_page.mirror.activity_changed_from_to", {
actor: actorName,
field: fieldLabel,
oldValue: item.old_value,
newValue: item.new_value,
});
}
if (item.new_value) {
return t("external_contours_page.mirror.activity_changed_to", {
actor: actorName,
field: fieldLabel,
newValue: item.new_value,
});
}
return t("external_contours_page.mirror.activity_changed", {
actor: actorName,
field: fieldLabel,
});
};
return (
<div className="space-y-3 py-4">
<div className="text-body-sm-medium">{t("external_contours_page.mirror.activity_title")}</div>
{activity.length > 0 ? (
<div className="space-y-3">
{activity.map((item) => (
<div key={item.id} className="rounded-md border border-subtle bg-surface-1 px-4 py-3">
<div className="text-13 text-primary">{renderMessage(item)}</div>
<div className="mt-1 text-11 text-secondary">{renderFormattedDate(item.created_at)}</div>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
{t("external_contours_page.mirror.activity_empty")}
</div>
)}
</div>
);
});

View File

@ -0,0 +1,64 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TExternalContourMirroredAttachment } from "@plane/types";
import { convertBytesToSize, getFileExtension, getFileName, renderFormattedDate } from "@plane/utils";
import { getFileIcon } from "@/components/icons";
type Props = {
attachments: TExternalContourMirroredAttachment[];
};
export const ExternalContoursMirroredAttachments = observer(function ExternalContoursMirroredAttachments(props: Props) {
const { attachments } = props;
const { t } = useTranslation();
return (
<div className="space-y-3 py-4">
<div className="text-body-sm-medium">{t("external_contours_page.mirror.attachments_title")}</div>
{attachments.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{attachments.map((attachment) => {
const fileName = getFileName(attachment.attributes?.name ?? "");
const fileExtension = getFileExtension(attachment.attributes?.name ?? attachment.asset_url ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
const fileSize = attachment.attributes?.size ? convertBytesToSize(attachment.attributes.size) : null;
return (
<a
key={attachment.id}
href={attachment.download_url || "#"}
target="_blank"
rel="noreferrer"
className="flex min-h-[60px] items-center justify-between gap-3 rounded-md border-[2px] border-subtle bg-surface-1 px-4 py-2 text-13 transition-colors hover:bg-surface-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="h-7 w-7 flex-shrink-0">{fileIcon}</div>
<div className="min-w-0">
<div className="truncate text-13 text-primary">{fileName || t("attachments")}</div>
<div className="flex flex-wrap items-center gap-2 text-11 text-secondary">
{fileExtension ? <span>{fileExtension.toUpperCase()}</span> : null}
{fileSize ? <span>{fileSize}</span> : null}
<span>{renderFormattedDate(attachment.updated_at)}</span>
</div>
</div>
</div>
<div className="text-right text-11 text-secondary">{attachment.uploaded_by || t("external_contours_page.mirror.system_actor")}</div>
</a>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
{t("external_contours_page.mirror.attachments_empty")}
</div>
)}
</div>
);
});

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TExternalContourMirroredComment } from "@plane/types";
import { Avatar } from "@plane/ui";
import { renderFormattedDate } from "@plane/utils";
type Props = {
comments: TExternalContourMirroredComment[];
};
export const ExternalContoursMirroredComments = observer(function ExternalContoursMirroredComments(props: Props) {
const { comments } = props;
const { t } = useTranslation();
return (
<div className="space-y-3 py-4">
<div className="text-body-sm-medium">{t("external_contours_page.mirror.comments_title")}</div>
{comments.length > 0 ? (
<div className="space-y-3">
{comments.map((comment) => {
const actorName = comment.actor_detail?.display_name || t("external_contours_page.mirror.system_actor");
return (
<div key={comment.id} className="rounded-md border border-subtle bg-surface-1 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Avatar src={comment.actor_detail?.avatar_url || ""} name={actorName} size="md" />
<div className="min-w-0">
<div className="truncate text-13 text-primary">{actorName}</div>
<div className="text-11 text-secondary">{renderFormattedDate(comment.created_at)}</div>
</div>
</div>
{comment.edited_at ? (
<div className="text-11 text-tertiary">{t("external_contours_page.mirror.comment_edited")}</div>
) : null}
</div>
<div
className="prose prose-invert max-w-none text-13 text-secondary [&_p]:mb-3"
dangerouslySetInnerHTML={{ __html: comment.comment_html || "<p></p>" }}
/>
</div>
);
})}
</div>
) : (
<div className="rounded-md border border-dashed border-subtle px-4 py-6 text-13 text-secondary">
{t("external_contours_page.mirror.comments_empty")}
</div>
)}
</div>
);
});

View File

@ -336,7 +336,31 @@ export default {
duplicate_of: "Duplicate of",
},
readonly_source_view:
"This request is shown in source-side mode. Direct access to the target contour is not required, and detailed activity and attachments will be synchronized in the next stage.",
"This request is shown in source-side mode. Direct access to the target contour is not required, and changes, comments, and attachments are mirrored here.",
mirror: {
attachments_title: "Attachments from the external contour",
attachments_empty: "No attachments have been added in the external contour yet.",
comments_title: "Comments from the external contour",
comments_empty: "No comments have been added in the external contour yet.",
activity_title: "External contour activity",
activity_empty: "No actions have been recorded for this request in the external contour yet.",
system_actor: "System",
comment_edited: "Edited",
activity_created: "{actor} created the request",
activity_changed: "{actor} changed the “{field}” field",
activity_changed_to: "{actor} changed the “{field}” field to “{newValue}”",
activity_changed_from_to: "{actor} changed the “{field}” field: “{oldValue}” → “{newValue}”",
fields: {
request: "request",
name: "title",
description: "description",
state: "status",
priority: "priority",
due_date: "due date",
assignees: "assignees",
labels: "labels",
},
},
traceability: {
title: "Routing",
description:

View File

@ -493,7 +493,31 @@ export default {
duplicate_of: "Дубликат",
},
readonly_source_view:
"Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а подробная активность и вложения будут синхронизированы следующим этапом.",
"Запрос показан в source-side режиме. Прямой доступ к целевому контуру не требуется, а изменения, комментарии и вложения подтягиваются сюда в зеркальном виде.",
mirror: {
attachments_title: "Вложения из внешнего контура",
attachments_empty: "Во внешнем контуре пока нет вложений.",
comments_title: "Комментарии из внешнего контура",
comments_empty: "Во внешнем контуре пока нет комментариев.",
activity_title: "Активность внешнего контура",
activity_empty: "Во внешнем контуре пока нет действий по этому запросу.",
system_actor: "Система",
comment_edited: "Изменено",
activity_created: "{actor} создал(а) запрос",
activity_changed: "{actor} изменил(а) поле «{field}»",
activity_changed_to: "{actor} изменил(а) поле «{field}» на «{newValue}»",
activity_changed_from_to: "{actor} изменил(а) поле «{field}»: «{oldValue}» → «{newValue}»",
fields: {
request: "запрос",
name: "заголовок",
description: "описание",
state: "статус",
priority: "приоритет",
due_date: "срок выполнения",
assignees: "исполнители",
labels: "метки",
},
},
traceability: {
title: "Маршрутизация",
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",

View File

@ -19,11 +19,48 @@ export type TExternalContourIssue = TIssue & {
state_detail?: IStateLite | null;
};
export type TExternalContourMirroredAttachment = {
id: string;
asset_url?: string | null;
attributes?: {
name?: string;
size?: number;
type?: string;
} | null;
download_url?: string | null;
updated_at: string;
uploaded_by?: string | null;
};
export type TExternalContourMirroredComment = {
id: string;
comment_html: string;
created_at: string;
updated_at: string;
edited_at?: string | null;
parent_id?: string | null;
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
};
export type TExternalContourMirroredActivity = {
id: string;
verb?: string | null;
field?: string | null;
old_value?: string | null;
new_value?: string | null;
comment?: string | null;
created_at: string;
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
};
export type TExternalContourRequest = {
created_at: string;
created_by: string | null;
id: string;
issue: TExternalContourIssue;
mirrored_activity?: TExternalContourMirroredActivity[];
mirrored_attachments?: TExternalContourMirroredAttachment[];
mirrored_comments?: TExternalContourMirroredComment[];
source_decision?: "accepted" | null;
source_decision_at?: string | null;
source_decision_by_name?: string | null;