Перейти к содержанию

Опросы в комментариях

Лёгкие голосовалки radio/checkbox, встраиваемые в комментарии. Один опрос на комментарий, 1..N вопросов, каждый с набором вариантов. Поддержка анонимного и открытого режимов, multiple-choice на уровне вопроса.

Не путать с Survey (полноценные опросы/тестирования с конструктором, таймерами, логикой ветвления) -- это отдельная подсистема (/api/survey/).

Структура данных

Polls 1──N PollsQuestions 1──N PollsQuestionsAnswersOptions 1──N PollsResponses
  │                                                                    │
  └── CommentId (FK)                                      UserId + AnswerOptionId (PK)
  │
  └── CommentsPolls (связка comment ↔ poll, cascade delete)

dbo.Polls

Колонка Тип Default Описание
Id INT IDENTITY PK
Guid UNIQUEIDENTIFIER newsequentialid() Внешний идентификатор
Title NVARCHAR(250) NULL Заголовок опроса
IsAnonymous BIT 1 Анонимный (по умолчанию -- да)
UserId INT FK Users Создатель
CommentId INT FK Comments Привязка к комментарию (UNIQUE)
Color TINYINT 0 Цвет (0-11, enum Color)

Индексы: PK на Id, UNIQUE на CommentId, UNIQUE на Guid, IX на UserId.

dbo.PollsQuestions

Колонка Тип Описание
Id INT IDENTITY PK
Guid UNIQUEIDENTIFIER
Question NVARCHAR(100) Текст вопроса
IsMultipleChoiceAllowed BIT (default 0) Можно ли выбрать несколько вариантов
QuestionOrder INT Порядок отображения
PollId INT FK Polls (CASCADE) Родительский опрос

dbo.PollsQuestionsAnswersOptions

Колонка Тип Описание
Id INT IDENTITY PK
Guid UNIQUEIDENTIFIER
AnswerOption NVARCHAR(100) Текст варианта ответа
AnswerOrder INT Порядок отображения
QuestionId INT FK PollsQuestions (CASCADE) Родительский вопрос

dbo.PollsResponses

Колонка Тип Описание
Guid UNIQUEIDENTIFIER
UserId INT PK(1) FK Users Кто проголосовал
AnswerOptionId INT PK(2) FK PollsQuestionsAnswersOptions (CASCADE) Выбранный вариант

Составной PK (AnswerOptionId, UserId) -- один голос на вариант на пользователя.

dbo.CommentsPolls

Связка комментарий-опрос (v2.266). Cascade delete в обе стороны.

Колонка Тип Описание
Guid UNIQUEIDENTIFIER
CommentId INT PK FK Comments (CASCADE)
PollId INT FK Polls (CASCADE)

DDL: DB_MSSQL/dbo.Polls.Table.sql, dbo.PollsQuestions.Table.sql, dbo.PollsQuestionsAnswersOptions.Table.sql, dbo.PollsResponses.Table.sql, dbo.CommentsPolls.Table.sql.

API

Контроллер: core/UniForm/UniForm.Api/Controllers/OldApi/Version2_0/Polls/PollsController.cs, route api/polls.

Метод Endpoint Назначение Ответ
GET /api/polls/{pollId} Получить опрос (вопросы + результаты для текущего пользователя) ApiResultDto<PollDto> / 404
POST /api/polls/response Проголосовать 204 / 400 / 404 / 409
POST /api/polls/unvoite Отменить голос 204 / 400 / 404

Создание опроса -- не через отдельный endpoint, а inline при создании комментария (PollsService.Create вызывается из Comments).

Body: PollResponsDto (голосование и отмена)

{
  "pollId": 123,
  "questions": [
    {
      "questionId": 1,
      "answersIds": [5, 7]
    }
  ]
}

Response: PollDto

{
  "pollId": 123,
  "title": "Выбери день для митапа",
  "isAnonymous": false,
  "color": 5,
  "isComplete": true,
  "commentId": 456,
  "respondedUsersCount": 12,
  "questions": [
    {
      "questionId": 1,
      "question": "Какой день удобнее?",
      "isMultipleChoiceAllowed": false,
      "answers": [
        { "answerId": 5, "answerOption": "Понедельник" },
        { "answerId": 6, "answerOption": "Среда" }
      ]
    }
  ],
  "results": [
    {
      "answers": [
        { "answerId": 5, "percent": 67, "countVoites": 8, "users": [...] },
        { "answerId": 6, "percent": 33, "countVoites": 4, "users": [...] }
      ]
    }
  ]
}

results = null если пользователь ещё не голосовал. В анонимном режиме users отсутствует, возвращается PollAnonymousAnswersResultDto (без списка голосовавших).

Создание опроса (PollCreateRequestDto)

{
  "title": "Заголовок",
  "isAnonymous": true,
  "color": 5,
  "questions": [
    {
      "question": "Текст вопроса",
      "isMultipleChoiceAllowed": false,
      "answers": [
        { "answerOption": "Вариант 1" },
        { "answerOption": "Вариант 2" }
      ]
    }
  ]
}

Передаётся как часть запроса создания комментария, не как отдельный API-вызов.

Бэкенд

PollsService

core/TCClassLib/Polls/PollsService.cs -- основная бизнес-логика.

Метод Назначение
Create(request, userId, commentId) → int Создание опроса + записи в CommentsPolls
GetPollForUser(pollId, sessionUserId) → PollDto Получение опроса с результатами. Маскирует voters в анонимных
GetPollIdsOfCommentsIds(commentsIds) → Dict Маппинг commentId → pollId (batch)
GetPollsOfCommentsForUser(commentsIds, sessionUserId) → Dict Batch-загрузка polls по комментариям
PostResponse(result, sessionUserId) Голосование. Валидация + вставка + SignalR notify
Unvoite(result, sessionUserId) Отмена голоса. Валидация + удаление из PollsResponses
AttachPollToComment(commentId, pollId) Привязка через CommentsPolls + инвалидация кеша

Entity-сервисы

core/TCDataAccess/Kernel/Services/Entity/Polls/: - PollsEntityService -- CRUD Polls (eager load Questions → Answers → Responses) - PollsQuestionsEntityService - PollsQuestionsAnswersOptionsEntityService - PollsResponsesEntityService - CommentsPollsEntityService

Кеширование

core/TCDataAccess/Caching/InMemory/CommentsPollsCache.cs -- in-memory кеш маппинга commentId → pollId, TTL 4 часа.

SignalR

Событие: pollCompleted (NotificationEvents ID = 51).

При голосовании PollsService отправляет CommentPollCompletedNotificationDto { CommentId, TaskId } через NotificationService. Handler: CommentPollCompletedNotificationHandler -- рассылает подписчикам задачи.

Клиент получает обновление через hub NotificationHub.

Валидации (бизнес-правила)

При голосовании: - Нельзя голосовать дважды в одном опросе (409 Conflict) - Все вопросы должны иметь ответы - Single-choice вопрос -- макс. 1 ответ - AnswerIds должны принадлежать вопросам этого опроса

На уровне БД: - Один опрос на комментарий (UNIQUE на CommentId) - Один голос на пользователь+вариант (PK) - CASCADE DELETE: удаление комментария удаляет опрос и все голоса

Цвета

Enum Color (core/Valhalla.Integration/.../Enums/Portal/Widget/Colors.cs):

Значение Имя
0 Default
1 Primary
2 Red
3 Pink
4 Purple
5 Green
6 Yellow
7 Orange
8 Blue
9 Cyan
10 Brown
11 Grey

Фронтенд

Компоненты в SPA (Angular):

Компонент Назначение
poll-config/ Форма создания опроса
poll-result/ Интерфейс голосования + отображение результатов
poll-stats/ Детальная статистика
create-poll-modal/ Модальное окно создания

REST-сервис (CommentsApiService):

Метод Назначение
getPoll(pollId) GET /api/polls/{pollId}
votePoll(params) POST /api/polls/response
unvotePoll(params) POST /api/polls/unvoite

Миграции

Версия Файл Изменение
v2.265 1757072365.1822426-Add-pools-infrastructure.sql Создание 4 таблиц (Polls, Questions, AnswersOptions, Responses)
v2.265 1759326404.1993587-Add-color-column-to-dbo-Polls-table.sql Добавление Color
v2.266 1763650565.2009321-add-table-dbo-CommentsPolls.sql Создание CommentsPolls
v2.266 1763727819.2009321-Copy-poll-to-comments-links-to-dbo-CommentsPolls.sql Миграция данных

Ограничения

  • Привязка к комментарию обязательна (CommentId NOT NULL) -- автономные опросы без комментария невозможны без ALTER
  • Один опрос на комментарий
  • Нет редактирования опроса после создания (только удалить комментарий + создать заново)
  • Текст вопроса -- макс. 100 символов, вариант ответа -- макс. 100 символов, заголовок -- макс. 250 символов
  • Нет API для получения списка всех опросов или фильтрации
  • Endpoint отмены голоса содержит опечатку: /api/polls/unvoite (вместо unvote)

Расширения

Проект mini-poll-widget расширяет Polls для портальных виджетов: добавляет dbo.WidgetPolls, делает Polls.CommentId nullable, и вводит lifecycle активации/деактивации.