Предметная областьМодулиСистема фидбека

Система пользовательского фидбека — дизайн-док

Актуально на: 2026-07-23

Статус: черновик на согласование · Дата: 2026-07-23 · Ветка: upd-0726

Документ из двух частей. Часть I — продуктовая (для созвонов и обсуждений, без кода). Часть II — техническая (модель, API, компоненты) — в конце, для реализации.



Часть I. Продукт

1. Зачем это

Единый механизм сбора обратной связи по всему приложению, чтобы понимать: удовлетворённость пользователей, находят ли они то, что искали, и где буксует опыт. Данные копятся в нашей БД и разбираются в админ-дашборде.

Зафиксированные решения:

  • Зоны сбора: маркетплейс (поиск/выбор), workspace-редактор БП, пост-действия (покупка/публикация/заказ), приложение в целом.
  • Форматы: «Нашли, что искали?» (да/нет), CSAT (оценка 1–5), NPS (0–10), свободный отзыв/баг-репорт. Всё через один движок.
  • Навязчивость: всегда доступный пассивный виджет + умные контекстные триггеры с частотным лимитом. Без агрессивных периодических опросов.
  • Хранение и разбор: своя модель в БД + админ-дашборд (лента с фильтрами, сводка метрик, модерация/статусы). Ответ пользователю на его фидбек — в бэклоге.

2. Что и где спрашиваем у пользователя

Два способа собрать отклик:

  • Пассивный виджет — плавающая кнопка «Оставить отзыв», доступна всегда. Пользователь сам решает написать (свободный отзыв + категория + быстрый CSAT).
  • Контекстные микро-опросы — короткая ненавязчивая карточка, всплывает в редкие значимые моменты (см. таблицу). Не модалка, легко закрыть.

Набор опросов и их точные формулировки (то, что увидит пользователь):

ОпросКогда показываемТекст
«Нашли, что искали?»пустая/слабая выдача поиска в маркетплейсе«Нашли то, что искали?» → при «Нет»: «Что искали? Это поможет улучшить каталог.»
CSAT покупкипосле успешной покупки«Как прошло оформление покупки?» (1–5)
CSAT публикациипосле публикации листинга«Насколько удобно было опубликовать листинг?» (1–5)
CSAT редакторазначимый прогресс в редакторе БП (см. §3)«Насколько удобно строить финмодель?» (1–5)
NPSпериодически (не чаще раза в 90 дней)«Насколько вероятно, что порекомендуете PlanForge?» (0–10)

3. Момент опроса про редактор БП

CSAT редактора спрашиваем, когда человек реально в нём поработал, но не в момент активного ввода. Считаем «значимым прогрессом» — срабатывает первое из:

  1. Первый экспорт/генерация БП — сигнал «дошёл до результата»;
  2. 3-я сессия над проектом — признак вовлечённости;
  3. 10+ минут активной работы за сессию — запасной сигнал.

⚠️ Экспорт финального БП — ещё недоработанная часть. Триггер подключаем неинвазивно (просто «слушаем» факт успешного экспорта, не трогая его логику), и условие №1 включаем только когда экспорт стабилизируется. До тех пор опрос редактора работает на условиях №2/№3 — чтобы не задеть WIP-функционал.

4. Что мы из этого поймём (метрики)

МетрикаЧто показывает
CSAT (сред. 1–5)удовлетворённость по зонам (покупка, публикация, редактор)
NPS (−100…+100)лояльность, готовность рекомендовать
Found-it rateдоля тех, кто нашёл нужное в маркетплейсе
Что именно не нашлитексты запросов, по которым выдача не сработала
Проблемные экраныстраницы с низким CSAT / жалобами
Объём и бэклогсколько откликов, сколько ещё не разобрано

Каждая запись несёт контекст (на каком экране, какой поисковый запрос, какой лот/проект) — без этого метрика не действенна.

5. Как не раздражаем

  • Не более 1 проактивного опроса за сессию и 1 раз в 3 дня на человека.
  • После ответа опрос не повторяется долго (NPS — 90 дней, CSAT — 2–3 недели).
  • Закрыл без ответа → надолго замолкаем; есть «Больше не показывать».
  • Молчим во время активного ввода в редакторе и в процессе оплаты.

6. Этапы внедрения

Фаза 1 — MVP: пассивный сбор + админка. Виджет «Оставить отзыв» (свободный отзыв + категория + быстрый CSAT) только для авторизованных · приём и хранение · лента в админке с фильтрами + базовая сводка. Уже даёт живой поток обратной связи.

Фаза 2 — контекстные триггеры + анонимы. Умные микро-опросы («Нашли, что искали?», CSAT после покупки) · правила частоты · подключаем сбор от гостей публичного маркетплейса и антиспам для них.

Фаза 3 — NPS + полная аналитика. Периодический NPS · опросы публикации и редактора · метрики и тренды в дашборде, топ проблемных экранов · теги/приоритеты.

Фаза 4 — бэклог: замыкание петли. Ответ автору фидбека (уведомление/email), шаблоны реакций. Сейчас не берём.

7. Решения и открытые вопросы

Решено:

  • Анонимы — сбор от гостей маркетплейса откладываем в Фазу 2; MVP собирает только у авторизованных.
  • Антиспам — решаем вместе с подключением анонимов (Фаза 2), не заранее.
  • Момент опроса редактора — первый экспорт → 3-я сессия → 10+ минут (см. §3).

Открыто:

  • Тексты опросов (§2) — согласовать формулировки.


Часть II. Техника

8. Модель данных (Prisma)

Две модели: FeedbackEntry (сам ответ) и FeedbackPromptEvent (журнал показов — основа частотного лимита, в т.ч. для анонимов). Стиль как в проекте: cuid(), enum’ы, индексы, onDelete: SetNull/Cascade.

model FeedbackEntry {
  id          String            @id @default(cuid())
 
  // Что за отклик
  type        FeedbackType                       // FOUND_IT | CSAT | NPS | FREEFORM
  zone        FeedbackZone                        // MARKETPLACE | WORKSPACE | POST_ACTION | APP_GLOBAL
  surveyKey   String?                             // ключ конкретного опроса из конфига движка
  source      FeedbackSource    @default(WIDGET)  // WIDGET (сам открыл) | TRIGGER (контекст) | SURVEY (периодич.)
 
  // Ответ (унифицированный)
  rating      Int?                                // FOUND_IT: 1/0 · CSAT: 1..5 · NPS: 0..10
  category    FeedbackCategory?                   // для FREEFORM: IDEA | PROBLEM | QUESTION | BUG
  comment     String?
 
  // Контекст
  route       String?                             // pathname, где оставлен отклик
  context     Json?                               // { searchQuery, listingId, projectId, resultsCount, ... }
 
  // Кто (авторизованный ИЛИ аноним)
  userId      String?
  anonymousId String?                             // first-party cookie pf_fb_anon для гостей
  userAgent   String?
  locale      String?
 
  // Модерация
  status      FeedbackStatus    @default(NEW)     // NEW | IN_PROGRESS | RESOLVED | SPAM
  priority    FeedbackPriority  @default(NORMAL)  // LOW | NORMAL | HIGH
  tags        String[]          @default([])
  adminNote   String?
  handledById String?
  handledAt   DateTime?
 
  createdAt   DateTime          @default(now())
 
  user      User? @relation("FeedbackAuthor",  fields: [userId],      references: [id], onDelete: SetNull)
  handledBy User? @relation("FeedbackHandler", fields: [handledById], references: [id], onDelete: SetNull)
 
  @@index([zone, type, createdAt])
  @@index([status, createdAt])
  @@index([userId])
  @@index([anonymousId])
}
 
model FeedbackPromptEvent {
  id          String              @id @default(cuid())
  surveyKey   String                                 // какой опрос
  action      FeedbackPromptAction                   // SHOWN | DISMISSED | ANSWERED | SNOOZED
  userId      String?
  anonymousId String?
  route       String?
  createdAt   DateTime            @default(now())
 
  @@index([surveyKey, userId, createdAt])
  @@index([surveyKey, anonymousId, createdAt])
}
 
enum FeedbackType         { FOUND_IT  CSAT  NPS  FREEFORM }
enum FeedbackZone         { MARKETPLACE  WORKSPACE  POST_ACTION  APP_GLOBAL }
enum FeedbackSource       { WIDGET  TRIGGER  SURVEY }
enum FeedbackCategory     { IDEA  PROBLEM  QUESTION  BUG }
enum FeedbackStatus       { NEW  IN_PROGRESS  RESOLVED  SPAM }
enum FeedbackPriority     { LOW  NORMAL  HIGH }
enum FeedbackPromptAction { SHOWN  DISMISSED  ANSWERED  SNOOZED }

На стороне User добавляются обратные связи feedbackEntries / handledFeedback.

rating Int? вместо отдельных колонок: один числовой столбец легко агрегировать, семантику задаёт type. Хелперы isPromoter/isDetractor (NPS) и isPositive (CSAT/FOUND_IT) — в lib/feedback.ts.

Миграции: по правилам проекта только prisma generate, синхронизацию БД оставляем пользователю. Готовлю SQL-файл в стиле add-*.sql (add-feedback.sql).

9. Движок форматов (конфиг опросов)

Опросы описаны конфигом в коде (не в БД) — один источник правды для текста, условий показа и лимитов.

// lib/feedback/surveys.ts
export const SURVEYS: SurveyConfig[] = [
  {
    key: "mkt.found_it",
    type: "FOUND_IT", zone: "MARKETPLACE", source: "TRIGGER",
    question: "Нашли то, что искали?",
    followupOnNo: "Что искали? Это поможет нам улучшить каталог.",
    trigger: "search:empty",            // пустая/нерелевантная выдача
    cooldownDays: 7, maxPerWeek: 1,
  },
  {
    key: "post.purchase_csat",
    type: "CSAT", zone: "POST_ACTION", source: "TRIGGER",
    question: "Как прошло оформление покупки?",
    trigger: "purchase:success",
    cooldownDays: 14,
  },
  {
    key: "post.publish_csat",
    type: "CSAT", zone: "POST_ACTION", source: "TRIGGER",
    question: "Насколько удобно было опубликовать листинг?",
    trigger: "listing:published",
    cooldownDays: 14,
  },
  {
    key: "workspace.editor_csat",
    type: "CSAT", zone: "WORKSPACE", source: "TRIGGER",
    question: "Насколько удобно строить финмодель?",
    trigger: "editor:milestone",        // см. §10 — набор условий, срабатывает первое
    cooldownDays: 21, quietDuringInput: true,
  },
  {
    key: "app.nps",
    type: "NPS", zone: "APP_GLOBAL", source: "SURVEY",
    question: "Насколько вероятно, что порекомендуете PlanForge?",
    trigger: "app:periodic",
    cooldownDays: 90, minAccountAgeDays: 7,
  },
];

Пассивный виджет (свободный отзыв + быстрый CSAT) не входит в конфиг триггеров — он доступен всегда по кнопке.

10. Триггеры и частотный лимит (governance)

Триггеры — доменные события, которые слушает провайдер фидбека:

  • search:empty — маркетплейс вернул 0/мало релевантных результатов.

  • purchase:success — экран успешной покупки.

  • listing:published — листинг ушёл на модерацию/опубликован.

  • editor:milestone — значимый прогресс в редакторе БП. Срабатывает первое из условий (по приоритету), с учётом cooldown и «тихих зон»:

    1. Первый экспорт/генерация БП (PDF/план-док).
    2. N-я сессия над проектом (напр. 3-я).
    3. N минут активной работы за сессию (напр. 10+), в паузе ввода.

    ⚠️ Экспорт финального БП — недоработанная часть. Подключаем неинвазивно: только feedbackBus.emit('editor:milestone', {reason:'export'}) в точке успешного экспорта, без вмешательства в логику генерации. Условие №1 включаем лишь когда экспорт стабилизируется; до тех пор — условия №2/№3.

  • app:periodic — фоновая проверка на NPS.

Глобальные правила (движок применяет поверх конфига):

  1. Не более 1 проактивного промпта за сессию.
  2. Не более 1 проактивного промпта раз в 3 дня на пользователя/анонима.
  3. Cooldown после ответа = cooldownDays опроса.
  4. Dismiss эскалирует suppression: 1-й отказ → 30д; 2-й → 90д; есть «Больше не показывать».
  5. Тихие зоны: активный ввод в редакторе и процесс checkout.
  6. Показ решается на сервере (GET /api/feedback/eligibility) — правила централизованы и работают для анонимов (журнал FeedbackPromptEvent); плюс лёгкий клиентский guard в localStorage.

Аноним идентифицируется first-party cookie pf_fb_anon (uuid, ставит провайдер).

11. UX-компоненты

Пассивный виджет (всегда):                Контекстный микро-опрос (триггер):
┌───────────────────────────┐             ┌───────────────────────────────┐
│  … контент страницы …      │             │  Нашли то, что искали?        │
│                            │             │     [ Да ]     [ Нет ]     ✕  │
│                     ╭────╮ │             └───────────────────────────────┘
│                     │ 💬 │ │              (ненавязчивая карточка снизу,
│                     ╰────╯ │              НЕ модалка; при «Нет» —
└───────────────────────────┘              раскрывается поле комментария)

Раскрытый виджет:
┌───────────────────────────┐
│  Поделитесь мнением     ✕ │
│  ○ Идея ○ Проблема ○ Баг  │
│  ☆☆☆☆☆  (быстрый CSAT)    │
│  ┌───────────────────────┐│
│  │ Ваш комментарий…      ││
│  └───────────────────────┘│
│           [ Отправить ]   │
└───────────────────────────┘

Компоненты (components/feedback/):

  • FeedbackProvider — монтируется в корневом/app-layout. Держит cookie анонима, слушает события-триггеры (шина feedbackBus.emit(...)), спрашивает eligibility, рендерит промпты, шлёт prompt-event.
  • FeedbackWidget — плавающая кнопка + панель (свободный отзыв, категория, быстрый CSAT). Скрывается в тихих зонах.
  • MicroSurvey — компактная нижняя карточка (Да/Нет или ряд звёзд) с опциональным follow-up. Не блокирует интерфейс, авто-скрытие по таймауту.

Точки эмита триггеров (интеграция в существующий код):

  • Маркетплейс: выдача при resultsCount === 0emit('search:empty', { query }).
  • Оплата: страница payment/после orderpurchase:success.
  • Публикация: после сабмита листинга (my-listings) → listing:published.
  • Редактор: workspace, при первом экспорте/сессии/таймере → editor:milestone.

12. API

Все ручки — в стиле проекта (getApiUser, json, unauthorized, forbidden, isAdmin из lib/), с rate-limit и валидацией по type.

Публичные / пользовательские

  • POST /api/feedback — приём отклика. Auth опционален (гость → anonymousId). Валидирует rating по диапазону типа, режет длину comment, антиспам (лимит N/час на аноним+IP). Пишет FeedbackEntry + FeedbackPromptEvent(ANSWERED). Опционально trackEvent('feedback_submitted', {...}) для воронок OpenPanel.
  • GET /api/feedback/eligibility?zone=&route= — сервер решает, какой опрос (если вообще) показать, применяя правила частоты. Возвращает surveyKey или null.
  • POST /api/feedback/prompt-event — журналирование SHOWN | DISMISSED | SNOOZED.

Админские (/api/admin/feedback, только isAdmin)

  • GET /api/admin/feedback — лента: пагинация + фильтры (zone, type, status, ratingMin/Max, hasComment, dateFrom/To, полнотекст по comment).
  • GET /api/admin/feedback/summary?period= — метрики: CSAT avg, NPS, found-it rate по зонам, объём, бэклог, топ проблемных route, тренды.
  • PATCH /api/admin/feedback/[id] — модерация: status, priority, tags, adminNote.

13. Админ-дашборд

Новая вкладка в app/(app)/admin/layout.tsx (массив TABS): { href: "/admin/feedback", label: "Фидбек", icon: IconMessage }.

Страница /admin/feedback (в стиле остальных admin-разделов):

  1. Карточки сводки: CSAT (за период), NPS, Found-it rate, новых/в работе.
  2. Тренды: спарклайны CSAT и NPS; доля «не нашёл» по зонам.
  3. Топ проблемных экранов: route с низким средним рейтингом/жалобами.
  4. Лента: таблица с фильтрами (зона, тип, оценка, статус, дата, есть коммент, поиск по тексту). Строка → детальная панель с полным context.
  5. Модерация: статус (NEW/IN_PROGRESS/RESOLVED/SPAM), приоритет, теги, заметка.

14. Приватность и правовое

  • От анонимов не собираем PII; comment — свободный текст, предупреждаем не вводить личные данные.
  • Учитываем систему согласий (LegalConsent/ConsentGate): строка про сбор обратной связи в политику; cookie pf_fb_anon — функциональный, отметить в cookie-политике.
  • При удалении аккаунта userIdSetNull (отклик остаётся обезличенным).