From c0cff00e82704e9073d60a0412ea7a414ab50f8c Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 6 Jul 2026 23:17:45 +0300 Subject: [PATCH] Add task links for feed posts and board defaults --- backend/api/v1/feed/serializers.py | 56 +++++ backend/api/v1/tasks/serializers.py | 98 ++++++++ backend/api/v1/tasks/views.py | 190 +++++++++++++++- backend/tasks/access.py | 5 + backend/tasks/admin.py | 8 + ...alter_taskactivity_object_kind_and_more.py | 23 ++ .../tasks/migrations/0012_taskusersettings.py | 30 +++ backend/tasks/models.py | 27 +++ backend/tests/api/v1/feed/test_posts.py | 93 ++++++++ backend/tests/api/v1/tasks/test_tasks_api.py | 127 ++++++++++- frontend/src/app/globals.css | 10 + frontend/src/app/page.tsx | 187 +++++++++------ frontend/src/app/tasks/page.tsx | 212 +++++++++++++++++- frontend/src/components/AppShell.tsx | 2 +- .../src/components/layout/MobileDrawers.tsx | 2 +- frontend/src/lib/api/tasks.ts | 16 ++ frontend/src/types/api.ts | 16 +- 17 files changed, 1027 insertions(+), 75 deletions(-) create mode 100644 backend/tasks/migrations/0011_alter_taskactivity_object_kind_and_more.py create mode 100644 backend/tasks/migrations/0012_taskusersettings.py diff --git a/backend/api/v1/feed/serializers.py b/backend/api/v1/feed/serializers.py index 8b46e34f..80c958d5 100644 --- a/backend/api/v1/feed/serializers.py +++ b/backend/api/v1/feed/serializers.py @@ -4,6 +4,7 @@ from __future__ import annotations from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE @@ -13,6 +14,53 @@ from ..utils import build_media_url Employee = get_user_model() +def _linked_task_payloads(post: Post, user) -> list[dict]: + if not user or not getattr(user, "is_authenticated", False): + return [] + + try: + from tasks.access import task_board_access_q + from tasks.models import ( + TaskBoard, + TaskLinkedObject, + TaskLinkedObjectKind, + ) + except Exception: + return [] + + content_type = ContentType.objects.get_for_model(Post) + accessible_boards = TaskBoard.objects.filter( + is_archived=False, + ).filter(task_board_access_q(user)) + + links = ( + TaskLinkedObject.objects.filter( + kind=TaskLinkedObjectKind.POST, + content_type=content_type, + object_id=post.id, + task__board__in=accessible_boards, + ) + .select_related("task", "task__board", "task__column") + .order_by("task__title", "task_id") + ) + + return [ + { + "link_id": link.id, + "id": link.task_id, + "title": link.task.title, + "board_id": link.task.board_id, + "board_name": link.task.board.name, + "column_id": link.task.column_id, + "column_name": link.task.column.name, + "column_color": link.task.column.color, + "priority": link.task.priority, + "priority_display": link.task.get_priority_display(), + } + for link in links + ] + + class AuthorMiniSerializer(serializers.ModelSerializer): full_name = serializers.SerializerMethodField() avatar = serializers.SerializerMethodField() @@ -68,6 +116,7 @@ class PostListSerializer(serializers.ModelSerializer): # Переопределяем поля для полных URL image = serializers.SerializerMethodField() attachment = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() class Meta: model = Post @@ -91,6 +140,7 @@ class PostListSerializer(serializers.ModelSerializer): "author_id", "author", "is_liked", + "linked_tasks", ] read_only_fields = ( "created_at", @@ -105,6 +155,7 @@ class PostListSerializer(serializers.ModelSerializer): "department_id", "department_name", "is_liked", + "linked_tasks", ) @extend_schema_field(OpenApiTypes.URI) @@ -126,6 +177,11 @@ class PostListSerializer(serializers.ModelSerializer): return bool(getattr(obj, "pinned_department", False)) return bool(getattr(obj, "pinned_global", False)) + def get_linked_tasks(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + return _linked_task_payloads(obj, user) + class PostSerializer(PostListSerializer): """Сериализатор для деталей поста""" diff --git a/backend/api/v1/tasks/serializers.py b/backend/api/v1/tasks/serializers.py index 88507359..9f66e008 100644 --- a/backend/api/v1/tasks/serializers.py +++ b/backend/api/v1/tasks/serializers.py @@ -16,6 +16,7 @@ from communications.utils import user_can_access_chat from documents.models import Document from employees.models import Department, EmployeeDepartment from employees.services.personnel_state import resolve_employee_personnel_state +from feed.models import Post from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest @@ -26,6 +27,7 @@ from tasks.access import ( user_can_access_document, user_can_access_employee, user_can_access_employee_request, + user_can_access_feed_post, user_can_access_guest, user_can_access_guest_visit, user_can_access_attendance_record, @@ -148,6 +150,7 @@ class TaskSerializer(serializers.ModelSerializer): board_name = serializers.CharField(source="board.name", read_only=True) column_name = serializers.CharField(source="column.name", read_only=True) column_color = serializers.CharField(source="column.color", read_only=True) + linked_posts_count = serializers.SerializerMethodField() linked_messages_count = serializers.SerializerMethodField() linked_events_count = serializers.SerializerMethodField() linked_documents_count = serializers.SerializerMethodField() @@ -181,6 +184,7 @@ class TaskSerializer(serializers.ModelSerializer): "due_date", "position", "completed_at", + "linked_posts_count", "linked_messages_count", "linked_events_count", "linked_documents_count", @@ -203,6 +207,7 @@ class TaskSerializer(serializers.ModelSerializer): "column_color", "priority_display", "completed_at", + "linked_posts_count", "linked_messages_count", "linked_events_count", "linked_documents_count", @@ -218,6 +223,14 @@ class TaskSerializer(serializers.ModelSerializer): "updated_at", ] + @extend_schema_field(serializers.IntegerField()) + def get_linked_posts_count(self, obj): + if hasattr(obj, "linked_posts_count"): + return obj.linked_posts_count + return obj.linked_objects.filter( + kind=TaskLinkedObjectKind.POST, + ).count() + @extend_schema_field(serializers.IntegerField()) def get_linked_messages_count(self, obj): if hasattr(obj, "linked_messages_count"): @@ -380,6 +393,7 @@ class TaskBoardSerializer(serializers.ModelSerializer): labels = TaskLabelSerializer(many=True, read_only=True) tasks = TaskSerializer(many=True, read_only=True) tasks_count = serializers.IntegerField(read_only=True, default=0) + is_default_for_current_user = serializers.SerializerMethodField() class Meta: model = TaskBoard @@ -397,10 +411,94 @@ class TaskBoardSerializer(serializers.ModelSerializer): "labels", "tasks", "tasks_count", + "is_default_for_current_user", "created_at", "updated_at", ] + @extend_schema_field(serializers.BooleanField()) + def get_is_default_for_current_user(self, obj): + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not getattr(user, "is_authenticated", False): + return False + settings_obj = getattr(user, "task_settings", None) + return bool(settings_obj and settings_obj.default_board_id == obj.id) + + +class TaskLinkedPostSerializer(serializers.ModelSerializer): + created_by = EmployeeBriefSerializer(read_only=True) + post = serializers.SerializerMethodField() + post_id = serializers.IntegerField(source="object_id", read_only=True) + can_open = serializers.SerializerMethodField() + object_url = serializers.SerializerMethodField() + + class Meta: + model = TaskLinkedObject + fields = [ + "id", + "kind", + "post_id", + "post", + "can_open", + "object_url", + "created_by", + "created_at", + ] + read_only_fields = fields + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_post(self, obj): + if obj.kind != TaskLinkedObjectKind.POST: + return None + + post = getattr(obj, "content_object", None) + if post is None: + return None + + request = self.context.get("request") + from api.v1.utils import build_media_url + + return { + "id": post.id, + "type": post.type, + "department": post.department_id, + "department_id": post.department_id, + "department_name": getattr(post.department, "name", None), + "title": post.title, + "body": post.body, + "image": build_media_url(post.image, request), + "attachment": build_media_url(post.attachment, request), + "created_at": post.created_at, + "pinned": post.pinned_global or post.pinned_department, + "pinned_global": post.pinned_global, + "pinned_department": post.pinned_department, + "likes_count": post.likes_count, + "comments_count": get_comment_count(post), + "author_id": post.author_id, + "author": EmployeeBriefSerializer(post.author).data, + } + + @extend_schema_field(serializers.BooleanField()) + def get_can_open(self, obj): + post = getattr(obj, "content_object", None) + request = self.context.get("request") + user = getattr(request, "user", None) + if obj.kind != TaskLinkedObjectKind.POST or post is None or not user: + return False + return user_can_access_feed_post(user, post) + + @extend_schema_field(serializers.CharField(allow_null=True)) + def get_object_url(self, obj): + post = getattr(obj, "content_object", None) + if post is None or not self.get_can_open(obj): + return None + return f"/?post={post.id}" + + +def get_post_content_type(): + return ContentType.objects.get_for_model(Post) + class TaskLinkedMessageSerializer(serializers.ModelSerializer): created_by = EmployeeBriefSerializer(read_only=True) diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 5335e055..7e314b93 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -15,6 +15,7 @@ from communications import comments_helpers from communications.models import Message from communications.utils import user_can_access_chat from documents.models import Document +from feed.models import Post from guests.models import Guest, GuestVisit from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest @@ -30,6 +31,7 @@ from tasks.access import ( user_can_access_document, user_can_access_employee, user_can_access_employee_request, + user_can_access_feed_post, user_can_access_guest, user_can_access_guest_visit, user_can_access_attendance_record, @@ -44,6 +46,7 @@ from tasks.models import ( TaskLabel, TaskLinkedObject, TaskLinkedObjectKind, + TaskUserSettings, ) from tasks.realtime import ( get_task_board_recipient_ids, @@ -63,6 +66,7 @@ from .serializers import ( TaskLinkedGuestSerializer, TaskLinkedGuestVisitSerializer, TaskLinkedMessageSerializer, + TaskLinkedPostSerializer, TaskLinkedProcurementRequestSerializer, TaskLinkedRequestSerializer, TaskSerializer, @@ -74,6 +78,7 @@ from .serializers import ( get_guest_content_type, get_guest_visit_content_type, get_message_content_type, + get_post_content_type, get_procurement_request_content_type, get_request_content_type, ) @@ -168,7 +173,21 @@ class TaskBoardViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["get"], url_path="default") def default(self, request): - board = self.get_queryset().order_by("id").first() + queryset = self.get_queryset() + settings_obj = ( + TaskUserSettings.objects.select_related("default_board") + .filter(user=request.user) + .first() + ) + board = None + if settings_obj and settings_obj.default_board_id: + board = queryset.filter(id=settings_obj.default_board_id).first() + if board is None: + settings_obj.default_board = None + settings_obj.save(update_fields=["default_board", "updated_at"]) + + if board is None: + board = queryset.order_by("id").first() if not board: board = TaskBoard.objects.create( name="Рабочая доска", @@ -179,6 +198,16 @@ class TaskBoardViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(self.get_queryset().get(id=board.id)) return Response(serializer.data) + @action(detail=True, methods=["post"], url_path="set-default") + def set_default(self, request, pk=None): + board = self.get_object() + TaskUserSettings.objects.update_or_create( + user=request.user, + defaults={"default_board": board}, + ) + serializer = self.get_serializer(self.get_queryset().get(id=board.id)) + return Response(serializer.data) + class TaskColumnViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] @@ -281,6 +310,11 @@ class TaskViewSet(viewsets.ModelViewSet): .select_related("board", "column", "created_by", "assignee") .prefetch_related("labels") .annotate( + linked_posts_count=Count( + "linked_objects", + filter=Q(linked_objects__kind=TaskLinkedObjectKind.POST), + distinct=True, + ), linked_messages_count=Count( "linked_objects", filter=Q(linked_objects__kind=TaskLinkedObjectKind.MESSAGE), @@ -799,6 +833,45 @@ class TaskViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-post-tasks") + def linked_post_tasks(self, request): + post_id = request.query_params.get("post_id") + try: + post_id = int(post_id) + except (TypeError, ValueError): + return Response( + {"post_id": ["Укажите ID новости."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + post = Post.objects.select_related("author", "department").get( + id=post_id, + ) + except Post.DoesNotExist: + return Response( + {"post_id": ["Новость не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_feed_post(request.user, post): + return Response( + {"detail": "Нет доступа к этой новости."}, + status=status.HTTP_403_FORBIDDEN, + ) + + queryset = ( + self.get_queryset() + .filter( + linked_objects__kind=TaskLinkedObjectKind.POST, + linked_objects__content_type=get_post_content_type(), + linked_objects__object_id=post.id, + ) + .distinct() + ) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + @action(detail=False, methods=["get"], url_path="linked-guest-tasks") def linked_guest_tasks(self, request): guest_id = request.query_params.get("guest_id") @@ -1004,6 +1077,121 @@ class TaskViewSet(viewsets.ModelViewSet): .order_by("-created_at", "-id") ) + def _post_links_queryset(self, task): + return ( + TaskLinkedObject.objects.filter( + task=task, + kind=TaskLinkedObjectKind.POST, + ) + .select_related("created_by", "content_type") + .order_by("-created_at", "-id") + ) + + @action(detail=True, methods=["get", "post"], url_path="linked-posts") + def linked_posts(self, request, pk=None): + task = self.get_object() + if request.method == "GET": + serializer = TaskLinkedPostSerializer( + self._post_links_queryset(task), + many=True, + context={"request": request}, + ) + return Response(serializer.data) + + post_id = request.data.get("post_id") + try: + post_id = int(post_id) + except (TypeError, ValueError): + return Response( + {"post_id": ["Укажите ID новости."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + post = Post.objects.select_related("author", "department").get( + id=post_id, + ) + except Post.DoesNotExist: + return Response( + {"post_id": ["Новость не найдена."]}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not user_can_access_feed_post(request.user, post): + return Response( + {"detail": "Нет доступа к этой новости."}, + status=status.HTTP_403_FORBIDDEN, + ) + + link, created = TaskLinkedObject.objects.get_or_create( + task=task, + kind=TaskLinkedObjectKind.POST, + content_type=get_post_content_type(), + object_id=post.id, + defaults={"created_by": request.user}, + ) + serializer = TaskLinkedPostSerializer( + link, + context={"request": request}, + ) + if created: + create_task_activity( + task, + request.user, + TaskActivityAction.LINKED, + object_kind=TaskLinkedObjectKind.POST, + object_id=post.id, + metadata={ + "object_label": post.title, + "object_type": "Новость", + }, + ) + send_task_board_update( + task.board, + "linked", + "post", + post.id, + extra={"task_id": task.id, "link_id": link.id}, + ) + return Response( + serializer.data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + @action( + detail=True, + methods=["delete"], + url_path=r"linked-posts/(?P\d+)", + ) + def unlink_post(self, request, pk=None, link_id=None): + task = self.get_object() + try: + link = self._post_links_queryset(task).get(id=link_id) + except TaskLinkedObject.DoesNotExist: + return Response( + {"detail": "Связь не найдена."}, + status=status.HTTP_404_NOT_FOUND, + ) + + post_id = link.object_id + link.delete() + create_task_activity( + task, + request.user, + TaskActivityAction.UNLINKED, + object_kind=TaskLinkedObjectKind.POST, + object_id=post_id, + metadata={"object_type": "Новость"}, + ) + send_task_board_update( + task.board, + "unlinked", + "post", + post_id, + extra={"task_id": task.id, "link_id": int(link_id)}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=["get", "post"], url_path="linked-messages") def linked_messages(self, request, pk=None): task = self.get_object() diff --git a/backend/tasks/access.py b/backend/tasks/access.py index b2d5b6c2..3dd5baf4 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -2,6 +2,7 @@ from django.db.models import Q from attendance.models import AttendanceRecord from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from feed.models import Post from requests_app.enums import RequestStatus from requests_app.models import Request as EmployeeRequest from schedule.models import Event @@ -175,3 +176,7 @@ def user_can_access_attendance_record( or user.is_superuser or record.employee_id == user.id ) + + +def user_can_access_feed_post(user, post: Post | None) -> bool: + return bool(user and user.is_authenticated and post is not None) diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 0f74ac47..4f5ced13 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -7,6 +7,7 @@ from .models import ( TaskColumn, TaskLabel, TaskLinkedObject, + TaskUserSettings, ) @@ -73,3 +74,10 @@ class TaskActivityAdmin(admin.ModelAdmin): list_display = ["task", "action", "actor", "object_kind", "object_id", "created_at"] list_filter = ["action", "object_kind", "created_at"] search_fields = ["task__title", "actor__email", "object_id"] + + +@admin.register(TaskUserSettings) +class TaskUserSettingsAdmin(admin.ModelAdmin): + list_display = ["user", "default_board", "updated_at"] + search_fields = ["user__email", "user__first_name", "user__last_name", "default_board__name"] + autocomplete_fields = ["user", "default_board"] diff --git a/backend/tasks/migrations/0011_alter_taskactivity_object_kind_and_more.py b/backend/tasks/migrations/0011_alter_taskactivity_object_kind_and_more.py new file mode 100644 index 00000000..3f4c8fc7 --- /dev/null +++ b/backend/tasks/migrations/0011_alter_taskactivity_object_kind_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.4 on 2026-07-06 19:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0010_alter_taskactivity_object_kind_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='taskactivity', + name='object_kind', + field=models.CharField(blank=True, choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип объекта'), + ), + migrations.AlterField( + model_name='tasklinkedobject', + name='kind', + field=models.CharField(choices=[('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'), + ), + ] diff --git a/backend/tasks/migrations/0012_taskusersettings.py b/backend/tasks/migrations/0012_taskusersettings.py new file mode 100644 index 00000000..15eb55c1 --- /dev/null +++ b/backend/tasks/migrations/0012_taskusersettings.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.4 on 2026-07-06 20:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0011_alter_taskactivity_object_kind_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TaskUserSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создано')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('default_board', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_user_settings', to='tasks.taskboard', verbose_name='Доска по умолчанию')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='task_settings', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Настройки задач пользователя', + 'verbose_name_plural': 'Настройки задач пользователей', + }, + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 0356032a..596d2948 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -47,6 +47,32 @@ class TaskBoard(models.Model): return self.name +class TaskUserSettings(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_settings", + verbose_name="Пользователь", + ) + default_board = models.ForeignKey( + TaskBoard, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="default_for_user_settings", + verbose_name="Доска по умолчанию", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Настройки задач пользователя" + verbose_name_plural = "Настройки задач пользователей" + + def __str__(self): + return f"{self.user} - задачи" + + class TaskColumn(models.Model): board = models.ForeignKey( TaskBoard, @@ -174,6 +200,7 @@ class Task(models.Model): class TaskLinkedObjectKind(models.TextChoices): + POST = "post", "Новость" MESSAGE = "message", "Сообщение" CALENDAR_EVENT = "calendar_event", "Календарное событие" DOCUMENT = "document", "Документ" diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index aaf49586..e78248d0 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile from employees.models import DepartmentPermission from employees.models import ( @@ -10,6 +11,13 @@ from employees.models import ( ) from feed.models import Post from rest_framework import status +from tasks.models import ( + Task, + TaskBoard, + TaskColumn, + TaskLinkedObject, + TaskLinkedObjectKind, +) from tests.test_config import API_POSTS_URL @@ -336,6 +344,91 @@ def test_list_department_posts_filtered_by_department( assert payload[0]["department_id"] == department_a.id +def test_post_list_includes_visible_linked_tasks_only( + auth_client_factory, + make_user, +): + viewer = make_user("post-linked-task-viewer@example.com") + hidden_member = make_user("post-linked-task-hidden@example.com") + post = Post.objects.create( + author=viewer, + type="company", + title="Новость со связью", + body="body", + ) + visible_board = TaskBoard.objects.create( + name="Доступная доска новости", + created_by=viewer, + ) + visible_column = TaskColumn.objects.create( + board=visible_board, + name="Новые", + position=1000, + color="#38bdf8", + ) + visible_task = Task.objects.create( + board=visible_board, + column=visible_column, + title="Видимая задача новости", + created_by=viewer, + priority="high", + ) + hidden_board = TaskBoard.objects.create( + name="Скрытая доска новости", + created_by=hidden_member, + ) + hidden_board.members.add(hidden_member) + hidden_column = TaskColumn.objects.create( + board=hidden_board, + name="Скрытые", + position=1000, + color="#ef4444", + ) + hidden_task = Task.objects.create( + board=hidden_board, + column=hidden_column, + title="Скрытая задача новости", + created_by=hidden_member, + priority="critical", + ) + content_type = ContentType.objects.get_for_model(Post) + visible_link = TaskLinkedObject.objects.create( + task=visible_task, + kind=TaskLinkedObjectKind.POST, + content_type=content_type, + object_id=post.id, + created_by=viewer, + ) + TaskLinkedObject.objects.create( + task=hidden_task, + kind=TaskLinkedObjectKind.POST, + content_type=content_type, + object_id=post.id, + created_by=hidden_member, + ) + + client = auth_client_factory(viewer) + response = client.get(API_POSTS_URL) + + assert response.status_code == status.HTTP_200_OK + payload = response.data["results"] if isinstance(response.data, dict) else response.data + post_payload = next(item for item in payload if item["id"] == post.id) + assert post_payload["linked_tasks"] == [ + { + "link_id": visible_link.id, + "id": visible_task.id, + "title": "Видимая задача новости", + "board_id": visible_board.id, + "board_name": "Доступная доска новости", + "column_id": visible_column.id, + "column_name": "Новые", + "column_color": "#38bdf8", + "priority": "high", + "priority_display": "Высокий", + } + ] + + def test_department_pin_scope_is_separate_from_global_feed( auth_client_factory, make_user, diff --git a/backend/tests/api/v1/tasks/test_tasks_api.py b/backend/tests/api/v1/tasks/test_tasks_api.py index a9c571a1..94db8d30 100644 --- a/backend/tests/api/v1/tasks/test_tasks_api.py +++ b/backend/tests/api/v1/tasks/test_tasks_api.py @@ -13,12 +13,13 @@ from attendance.models import AttendanceAnalysisRun, AttendanceRecord from communications.models import Chat, ChatMembership, Message from documents.models import Document from employees.models import Department, EmployeeDepartment, RoleAssignment +from feed.models import Post from guests.models import Guest, GuestVisit from procurement.constants import ProcurementStatus, UrgencyLevel from procurement.models import ProcurementRequest from requests_app.models import Request as EmployeeRequest from schedule.models import Calendar, CalendarRelation, Event -from tasks.models import Task, TaskBoard, TaskColumn +from tasks.models import Task, TaskBoard, TaskColumn, TaskUserSettings from tasks.realtime import get_task_board_recipient_ids pytestmark = pytest.mark.django_db @@ -77,6 +78,61 @@ def test_default_board_creates_work_columns(api_client, user): assert TaskColumn.objects.count() == 4 +def test_user_can_set_personal_default_board(api_client, user): + api_client.force_authenticate(user=user) + first_board = api_client.get( + reverse("api:v1:tasks:task-board-default") + ).data + second_board = TaskBoard.objects.create( + name="Персональная доска по умолчанию", + created_by=user, + ) + TaskColumn.objects.create( + board=second_board, + name="Новые", + position=1000, + ) + + set_response = api_client.post( + reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": second_board.id}) + ) + + assert set_response.status_code == status.HTTP_200_OK + assert set_response.data["id"] == second_board.id + assert set_response.data["is_default_for_current_user"] is True + assert TaskUserSettings.objects.get(user=user).default_board_id == second_board.id + + default_response = api_client.get(reverse("api:v1:tasks:task-board-default")) + assert default_response.status_code == status.HTTP_200_OK + assert default_response.data["id"] == second_board.id + assert default_response.data["is_default_for_current_user"] is True + + other_user = make_user("task-default-other@example.com", "+79994440090") + api_client.force_authenticate(user=other_user) + other_default_response = api_client.get(reverse("api:v1:tasks:task-board-default")) + assert other_default_response.status_code == status.HTTP_200_OK + assert other_default_response.data["id"] == first_board["id"] + assert other_default_response.data["is_default_for_current_user"] is False + + +def test_user_cannot_set_inaccessible_board_as_default(api_client): + owner = make_user("task-default-owner@example.com", "+79994440091") + outsider = make_user("task-default-outsider@example.com", "+79994440092") + board = TaskBoard.objects.create( + name="Закрытая доска", + created_by=owner, + ) + board.members.add(owner) + + api_client.force_authenticate(user=outsider) + response = api_client.post( + reverse("api:v1:tasks:task-board-set-default", kwargs={"pk": board.id}) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert not TaskUserSettings.objects.filter(user=outsider).exists() + + def test_create_task_and_move_to_done_column(api_client, user): api_client.force_authenticate(user=user) board_response = api_client.get( @@ -820,6 +876,75 @@ def test_task_linked_guest_respects_task_board_access(api_client): assert reverse_linked_response.data == [] +def test_task_linked_post_respects_task_board_access(api_client): + owner = make_user("task-post-owner@example.com", "+79994440045") + board_member = make_user("task-post-member@example.com", "+79994440046") + outsider = make_user("task-post-outsider@example.com", "+79994440047") + post = Post.objects.create( + author=owner, + type="company", + title="Новости задач", + body="Связать публикацию с задачей", + ) + board = TaskBoard.objects.create( + name="Доска с новостями", + created_by=owner, + ) + board.members.add(board_member) + column = TaskColumn.objects.create( + board=board, + name="Новые", + position=1000, + color="#38bdf8", + ) + task = Task.objects.create( + board=board, + column=column, + title="Обработать новость", + created_by=owner, + ) + + api_client.force_authenticate(user=board_member) + link_response = api_client.post( + reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id}), + {"post_id": post.id}, + format="json", + ) + assert link_response.status_code == status.HTTP_201_CREATED + assert link_response.data["post_id"] == post.id + assert link_response.data["post"]["title"] == "Новости задач" + assert link_response.data["post"]["body"] == "Связать публикацию с задачей" + assert link_response.data["can_open"] is True + assert link_response.data["object_url"] == f"/?post={post.id}" + + linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id}) + ) + assert linked_response.status_code == status.HTTP_200_OK + assert linked_response.data[0]["post"]["author"]["id"] == owner.id + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-post-tasks"), + {"post_id": post.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data[0]["id"] == task.id + assert reverse_linked_response.data[0]["linked_posts_count"] == 1 + + api_client.force_authenticate(user=outsider) + task_detail_response = api_client.get( + reverse("api:v1:tasks:task-linked-posts", kwargs={"pk": task.id}) + ) + assert task_detail_response.status_code == status.HTTP_404_NOT_FOUND + + reverse_linked_response = api_client.get( + reverse("api:v1:tasks:task-linked-post-tasks"), + {"post_id": post.id}, + ) + assert reverse_linked_response.status_code == status.HTTP_200_OK + assert reverse_linked_response.data == [] + + def test_task_linked_guest_visit_respects_visit_access(api_client): owner = make_user("task-guest-visit-owner@example.com", "+79994440039") inviter = make_user("task-guest-visit-inviter@example.com", "+79994440040") diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 543de4d6..d8746a52 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -492,6 +492,16 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator { background: var(--surface-secondary); } +.app-menu-danger-item { + border: 1px solid transparent; + background: transparent; + color: var(--danger-foreground); +} + +.app-menu-danger-item:hover { + background: var(--danger-soft); +} + .app-action-danger { border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong)); background: var(--surface-elevated); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3f5dc108..033ff840 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,12 +1,14 @@ "use client"; import Link from "next/link"; -import { ChevronRight, Pencil, Pin, Plus, Trash2 } from "lucide-react"; +import { ChevronRight, Link2, Pencil, Pin, Plus, Trash2 } from "lucide-react"; import { AppShell } from "../components/AppShell"; import { FeedPostCard } from "@/components/feed/FeedPostCard"; import { Modal } from "@/components/ui"; import { PostCommentsModal } from "@/components/feed/PostCommentsModal"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; +import { RelatedTaskLinks } from "@/components/tasks/RelatedTaskLinks"; +import TaskLinkPill from "@/components/tasks/TaskLinkPill"; import { useNotifications } from "@/contexts/NotificationsContext"; import { apiClient } from "@/lib/api"; import { userProfileLink } from "@/lib/shared"; @@ -76,6 +78,7 @@ function HomePageContent() { const [likesUsersEndpointUnavailable, setLikesUsersEndpointUnavailable] = useState(false); const [imageCacheBuster, setImageCacheBuster] = useState>({}); const [postMenuOpenId, setPostMenuOpenId] = useState(null); + const [taskLinkPost, setTaskLinkPost] = useState(null); const postMenuRef = useRef(null); const formatUserName = (u: LikeUser) => { @@ -208,6 +211,18 @@ function HomePageContent() { ); }, []); + const getPostTaskSubtitle = (post: Post) => { + if (post.type === "department") { + return post.department_name ? `Отдел: ${post.department_name}` : "Отдел"; + } + return "Компания"; + }; + + const getPostDefaultTaskTitle = (post: Post) => { + const title = (post.title || "").trim(); + return title ? `Задача по новости: ${title}` : `Задача по новости #${post.id}`; + }; + const refreshPosts = useCallback(async () => { const response = await apiClient.getPosts(); setPosts(sortPostsPinnedFirst(response.results)); @@ -476,6 +491,11 @@ function HomePageContent() { setCommentsOpen(true); }; + const openTaskLinkModal = (post: Post) => { + setPostMenuOpenId(null); + setTaskLinkPost(post); + }; + const applyCommentCountDelta = (postId: number, delta: number) => { setPosts((prev) => prev.map((item) => @@ -686,6 +706,7 @@ function HomePageContent() { : diffHours < 24 ? `${diffHours} ч. назад` : `${Math.floor(diffHours / 24)} дн. назад`; + const linkedTasks = post.linked_tasks || []; return ( } headerActions={( - (canEditPost(post) || canDeletePost(post)) ? ( -
+ + + - {postMenuOpenId === post.id ? ( -
- {canPinPost ? ( - - ) : null} - {canEditPost(post) ? ( - - ) : null} - {canDeletePost(post) ? ( - - ) : null} -
- ) : null} -
- ) : null + {postMenuOpenId === post.id ? ( +
+ + {canPinPost ? ( + + ) : null} + {canEditPost(post) ? ( + + ) : null} + {canDeletePost(post) ? ( + + ) : null} +
+ ) : null} + )} + footerAction={linkedTasks.length > 0 ? ( +
+ {linkedTasks.slice(0, 3).map((task) => ( + + ))} + {linkedTasks.length > 3 ? ( + + +{linkedTasks.length - 3} + + ) : null} +
+ ) : null} imageSrc={withImageCacheBuster(resolveMediaUrl(post.image), post.id)} isLikeActive={Boolean(post.is_liked)} likeDisabled={likeBusyId === post.id} @@ -935,6 +978,24 @@ function HomePageContent() { onCommentCountChange={applyCommentCountDelta} post={activePost} /> + + {taskLinkPost ? ( + apiClient.getPostLinkedTasks(taskLinkPost.id)} + linkTask={(taskId) => apiClient.linkTaskPost(taskId, taskLinkPost.id)} + onClose={() => setTaskLinkPost(null)} + onLinked={() => void refreshPosts()} + /> + ) : null} ); } diff --git a/frontend/src/app/tasks/page.tsx b/frontend/src/app/tasks/page.tsx index 7d82aeb2..21a2a839 100644 --- a/frontend/src/app/tasks/page.tsx +++ b/frontend/src/app/tasks/page.tsx @@ -27,12 +27,14 @@ import { Link2, Loader2, MessageSquare, + Newspaper, Paperclip, Pencil, Plus, RefreshCw, Search, ShoppingCart, + Star, Tag, Trash2, UserRound, @@ -63,6 +65,7 @@ import type { TaskLinkedGuest, TaskLinkedGuestVisit, TaskLinkedMessage, + TaskLinkedPost, TaskLinkedProcurementRequest, TaskLinkedRequest, TaskPriority, @@ -272,11 +275,11 @@ function TaskCardView({ onClick={() => onOpen(task)} className="min-w-0 flex-1 text-left" > -
+
#{task.id} -

+

{task.title}

@@ -384,11 +387,11 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
-
+
#{task.id} -

+

{task.title}

@@ -487,13 +490,13 @@ function BoardColumn({ }`} >
-
+
-

+

{column.name}

@@ -550,6 +553,117 @@ function AddColumnCard({ onClick }: { onClick: () => void }) { ); } +function getLinkedPostOriginLabel(post: TaskLinkedPost["post"]) { + if (!post) return ""; + if (post.type === "department") { + return post.department_name ? `Отдел: ${post.department_name}` : "Отдел"; + } + return "Компания"; +} + +function LinkedPostCard({ + link, + onUnlink, + disabled, +}: { + link: TaskLinkedPost; + onUnlink: (linkId: number) => void; + disabled?: boolean; +}) { + const post = link.post; + const canOpen = Boolean(link.can_open && link.object_url); + const postText = (post?.body || post?.content || "").trim(); + const originLabel = getLinkedPostOriginLabel(post); + const mainContent = ( + <> +
+
+ + + Новость + + {originLabel ? ( + + {originLabel} + + ) : null} + {canOpen ? ( + + Открыть + + ) : null} +
+

+ {post?.title || "Новость не найдена"} +

+ {post?.created_at ? ( +

+ {formatDateTime(post.created_at)} +

+ ) : null} +
+ + {postText ? ( +

+ {postText} +

+ ) : null} + +
+ {post?.author ? ( + + {displayUserName(post.author)} + + ) : null} + {typeof post?.likes_count === "number" ? ( + + {post.likes_count} лайк. + + ) : null} + {typeof post?.comments_count === "number" ? ( + + {post.comments_count} комм. + + ) : null} + + Связал: {link.created_by ? displayUserName(link.created_by) : "не указано"} + +
+ + ); + + return ( +
+
+ {canOpen && link.object_url ? ( + + {mainContent} + + ) : ( +
+ {mainContent} +
+ )} + + +
+
+ ); +} + function LinkedMessageCard({ link, onUnlink, @@ -1569,6 +1683,7 @@ export default function TasksPage() { const [labelName, setLabelName] = useState(""); const [labelColor, setLabelColor] = useState("#38bdf8"); const [linkedObjectsOpen, setLinkedObjectsOpen] = useState(false); + const [linkedPosts, setLinkedPosts] = useState([]); const [linkedMessages, setLinkedMessages] = useState([]); const [linkedEvents, setLinkedEvents] = useState([]); const [linkedDocuments, setLinkedDocuments] = useState([]); @@ -1623,6 +1738,7 @@ export default function TasksPage() { setLinkedObjectsLoading(true); try { const [ + posts, messages, events, documents, @@ -1633,6 +1749,7 @@ export default function TasksPage() { guestVisits, attendanceRecords, ] = await Promise.all([ + apiClient.getTaskLinkedPosts(taskId), apiClient.getTaskLinkedMessages(taskId), apiClient.getTaskLinkedEvents(taskId), apiClient.getTaskLinkedDocuments(taskId), @@ -1643,6 +1760,7 @@ export default function TasksPage() { apiClient.getTaskLinkedGuestVisits(taskId), apiClient.getTaskLinkedAttendanceRecords(taskId), ]); + setLinkedPosts(posts); setLinkedMessages(messages); setLinkedEvents(events); setLinkedDocuments(documents); @@ -1984,7 +2102,7 @@ export default function TasksPage() { () => activeColumns.find((column) => column.id === viewTask?.column) || null, [activeColumns, viewTask?.column], ); - const linkedObjectsCount = linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length; + const linkedObjectsCount = linkedPosts.length + linkedMessages.length + linkedEvents.length + linkedDocuments.length + linkedRequests.length + linkedProcurementRequests.length + linkedEmployees.length + linkedGuests.length + linkedGuestVisits.length + linkedAttendanceRecords.length; const linkedObjectsBadgeCount = viewTask?.linked_objects_count ?? linkedObjectsCount; const commentsBadgeCount = viewTask?.comments_count ?? taskComments.length; @@ -1999,6 +2117,7 @@ export default function TasksPage() { useEffect(() => { if (!viewTaskId) { + setLinkedPosts([]); setLinkedMessages([]); setLinkedEvents([]); setLinkedDocuments([]); @@ -2206,6 +2325,33 @@ export default function TasksPage() { } }, [board, loadBoard, loadBoards]); + const setDefaultSelectedBoard = useCallback(async () => { + if (!board || saving || board.is_default_for_current_user) return; + + setBoardActionsMenuOpen(false); + setSaving(true); + setError(null); + try { + const updatedBoard = await apiClient.setDefaultTaskBoard(board.id); + setBoard((current) => ( + current + ? { + ...current, + is_default_for_current_user: current.id === updatedBoard.id, + } + : current + )); + setBoards((current) => current.map((item) => ({ + ...item, + is_default_for_current_user: item.id === updatedBoard.id, + }))); + } catch (defaultError) { + setError(getTaskError(defaultError, "Не удалось выбрать доску по умолчанию")); + } finally { + setSaving(false); + } + }, [board, saving]); + const deleteSelectedBoard = useCallback(async () => { if (!board || saving) return; if (!window.confirm(`Удалить доску "${board.name}"? Все задачи этой доски будут удалены.`)) return; @@ -2413,6 +2559,23 @@ export default function TasksPage() { } }, [board, loadBoard, saving, viewTask]); + const unlinkPostFromViewedTask = useCallback(async (linkId: number) => { + if (!board || !viewTask || saving) return; + + setSaving(true); + setError(null); + try { + await apiClient.unlinkTaskPost(viewTask.id, linkId); + await loadTaskLinkedObjects(viewTask.id); + await loadBoard(board.id); + if (activityOpen) await loadTaskActivity(viewTask.id); + } catch (unlinkError) { + setError(getTaskError(unlinkError, "Не удалось убрать связь с новостью")); + } finally { + setSaving(false); + } + }, [activityOpen, board, loadBoard, loadTaskActivity, loadTaskLinkedObjects, saving, viewTask]); + const unlinkMessageFromViewedTask = useCallback(async (linkId: number) => { if (!board || !viewTask || saving) return; @@ -2680,6 +2843,26 @@ export default function TasksPage() { Обновить + ); })} @@ -3352,6 +3542,14 @@ export default function TasksPage() {
) : linkedObjectsCount > 0 ? ( <> + {linkedPosts.map((link) => ( + + ))} {linkedMessages.map((link) => (
diff --git a/frontend/src/components/layout/MobileDrawers.tsx b/frontend/src/components/layout/MobileDrawers.tsx index ecf2ff1e..fae3565c 100644 --- a/frontend/src/components/layout/MobileDrawers.tsx +++ b/frontend/src/components/layout/MobileDrawers.tsx @@ -86,7 +86,7 @@ export function MobileLeftDrawer({ isOpen, onClose, children }: LeftNavDrawerPro onClick={() => { onClose(); router.push("/settings"); }} >Настройки
diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index e9f626ba..6ede6d63 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -13,6 +13,7 @@ import type { TaskLinkedGuest, TaskLinkedGuestVisit, TaskLinkedMessage, + TaskLinkedPost, TaskLinkedProcurementRequest, TaskLinkedRequest, } from "@/types/api"; @@ -38,6 +39,8 @@ export function createTasksApi(request: RequestFn) { }), deleteTaskBoard: (id: number): Promise => request(`/api/v1/tasks/boards/${id}/`, { method: "DELETE" }), + setDefaultTaskBoard: (id: number): Promise => + request(`/api/v1/tasks/boards/${id}/set-default/`, { method: "POST" }), createTaskColumn: (data: Record): Promise => request("/api/v1/tasks/columns/", { method: "POST", @@ -79,6 +82,8 @@ export function createTasksApi(request: RequestFn) { request(`/api/v1/tasks/${id}/comments/${commentId}/`, { method: "DELETE", }), + getPostLinkedTasks: (postId: number): Promise => + request(`/api/v1/tasks/linked-post-tasks/?post_id=${postId}`), getCalendarEventLinkedTasks: (eventId: number): Promise => request(`/api/v1/tasks/linked-event-tasks/?event_id=${eventId}`), getDocumentLinkedTasks: (documentId: number): Promise => @@ -95,6 +100,17 @@ export function createTasksApi(request: RequestFn) { request(`/api/v1/tasks/linked-guest-visit-tasks/?guest_visit_id=${guestVisitId}`), getAttendanceRecordLinkedTasks: (attendanceRecordId: number): Promise => request(`/api/v1/tasks/linked-attendance-record-tasks/?attendance_record_id=${attendanceRecordId}`), + getTaskLinkedPosts: (id: number): Promise => + request(`/api/v1/tasks/${id}/linked-posts/`), + linkTaskPost: (id: number, postId: number): Promise => + request(`/api/v1/tasks/${id}/linked-posts/`, { + method: "POST", + body: JSON.stringify({ post_id: postId }), + }), + unlinkTaskPost: (id: number, linkId: number): Promise => + request(`/api/v1/tasks/${id}/linked-posts/${linkId}/`, { + method: "DELETE", + }), getTaskLinkedMessages: (id: number): Promise => request(`/api/v1/tasks/${id}/linked-messages/`), linkTaskMessage: (id: number, messageId: number): Promise => diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0a246fba..1db7975c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -180,6 +180,7 @@ export interface TaskCard { due_date?: string | null; position: number; completed_at?: string | null; + linked_posts_count?: number; linked_messages_count?: number; linked_events_count?: number; linked_documents_count?: number; @@ -209,10 +210,22 @@ export interface TaskBoard { labels: TaskLabel[]; tasks: TaskCard[]; tasks_count?: number; + is_default_for_current_user?: boolean; created_at: string; updated_at: string; } +export interface TaskLinkedPost { + id: number; + kind: "post"; + post_id: number; + post: Post | null; + can_open?: boolean; + object_url?: string | null; + created_by?: User; + created_at: string; +} + export interface TaskLinkedMessage { id: number; kind: "message"; @@ -412,7 +425,7 @@ export interface TaskActivity { action: "created" | "updated" | "moved" | "linked" | "unlinked"; action_display?: string; actor?: User | null; - object_kind?: "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | ""; + object_kind?: "post" | "message" | "calendar_event" | "document" | "request" | "procurement_request" | "employee" | "guest" | "guest_visit" | "attendance_record" | ""; object_id?: number | null; metadata?: Record; created_at: string; @@ -561,6 +574,7 @@ export interface Post { likes_count: number; comments_count: number; is_liked?: boolean; + linked_tasks?: MessageLinkedTask[]; } export interface Comment {