Реалізація системи голосування за фічі на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація системи голосування за фічі на сайті
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація системи голосування за функції на веб-сайті

Публічна дорожна карта з голосуванням розв'язує дві проблеми одночасно: команда бачить реальний пріоритет запитів замість суб'єктивних оцінок, користувачі бачать, що їх чують, і можуть відстежувати статус. Продукти на кшталт Canny та ProductBoard роблять саме це — але іноді потрібне власне рішення без залежності від SaaS і з повним контролем над даними.

Схема бази даних

CREATE TABLE feature_requests (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title       TEXT NOT NULL,
  description TEXT NOT NULL,
  category    TEXT NOT NULL,
  status      TEXT NOT NULL DEFAULT 'proposed'
                CHECK (status IN ('proposed','planned','in_progress','done','declined')),
  votes_count INTEGER NOT NULL DEFAULT 0,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE feature_votes (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  request_id UUID NOT NULL REFERENCES feature_requests(id) ON DELETE CASCADE,
  user_id    UUID,                          -- NULL для анонімних
  fingerprint TEXT,                         -- для анонімних: хеш IP+UA
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (request_id, user_id),
  UNIQUE (request_id, fingerprint)
);

-- тригер для синхронного лічильника
CREATE OR REPLACE FUNCTION update_votes_count()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    UPDATE feature_requests SET votes_count = votes_count + 1 WHERE id = NEW.request_id;
  ELSIF TG_OP = 'DELETE' THEN
    UPDATE feature_requests SET votes_count = votes_count - 1 WHERE id = OLD.request_id;
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_votes_count
AFTER INSERT OR DELETE ON feature_votes
FOR EACH ROW EXECUTE FUNCTION update_votes_count();

API

// routes/features.ts (Express + Prisma)
import { Router } from 'express';
import { createHash } from 'crypto';

export const featuresRouter = Router();

// Список запитів з пагінацією та фільтром
featuresRouter.get('/', async (req, res) => {
  const { category, status, sort = 'votes', page = '1' } = req.query;
  const take = 20;
  const skip = (Number(page) - 1) * take;

  const where: Prisma.FeatureRequestWhereInput = {};
  if (category) where.category = String(category);
  if (status) where.status = String(status);

  const [items, total] = await Promise.all([
    prisma.featureRequest.findMany({
      where,
      orderBy: sort === 'votes'
        ? { votesCount: 'desc' }
        : { createdAt: 'desc' },
      skip,
      take,
      include: {
        _count: { select: { votes: true } },
      },
    }),
    prisma.featureRequest.count({ where }),
  ]);

  // Додаємо прапор "голосував ли поточний користувач"
  const userId = req.user?.id;
  const fingerprint = getUserFingerprint(req);

  const votedIds = userId
    ? await prisma.featureVote.findMany({
        where: { requestId: { in: items.map(i => i.id) }, userId },
        select: { requestId: true },
      }).then(vs => new Set(vs.map(v => v.requestId)))
    : await prisma.featureVote.findMany({
        where: { requestId: { in: items.map(i => i.id) }, fingerprint },
        select: { requestId: true },
      }).then(vs => new Set(vs.map(v => v.requestId)));

  res.json({
    items: items.map(item => ({ ...item, hasVoted: votedIds.has(item.id) })),
    total,
    page: Number(page),
    pages: Math.ceil(total / take),
  });
});

// Голосування (toggle)
featuresRouter.post('/:id/vote', async (req, res) => {
  const { id } = req.params;
  const userId = req.user?.id ?? null;
  const fingerprint = getUserFingerprint(req);

  const existingVote = await prisma.featureVote.findFirst({
    where: userId
      ? { requestId: id, userId }
      : { requestId: id, fingerprint },
  });

  if (existingVote) {
    await prisma.featureVote.delete({ where: { id: existingVote.id } });
    return res.json({ action: 'removed' });
  }

  await prisma.featureVote.create({
    data: { requestId: id, userId, fingerprint: userId ? null : fingerprint },
  });

  res.json({ action: 'added' });
});

function getUserFingerprint(req: Request): string {
  const ip = req.ip ?? '';
  const ua = req.headers['user-agent'] ?? '';
  return createHash('sha256').update(ip + ua).digest('hex');
}

Фронтенд: список з голосуванням

// FeatureList.tsx
import { useState, useOptimistic } from 'react';

interface FeatureItem {
  id: string;
  title: string;
  description: string;
  category: string;
  status: 'proposed' | 'planned' | 'in_progress' | 'done' | 'declined';
  votesCount: number;
  hasVoted: boolean;
}

const STATUS_LABELS: Record<FeatureItem['status'], { label: string; color: string }> = {
  proposed:    { label: 'Запропоновано',    color: 'bg-gray-100 text-gray-700' },
  planned:     { label: 'В плані',       color: 'bg-blue-100 text-blue-700' },
  in_progress: { label: 'В розроблені', color: 'bg-yellow-100 text-yellow-700' },
  done:        { label: 'Готово',        color: 'bg-green-100 text-green-700' },
  declined:    { label: 'Відхилено',     color: 'bg-red-100 text-red-700' },
};

function VoteButton({
  id,
  votesCount,
  hasVoted,
}: {
  id: string;
  votesCount: number;
  hasVoted: boolean;
}) {
  const [optimisticState, setOptimistic] = useOptimistic(
    { count: votesCount, voted: hasVoted },
    (state, action: 'toggle') => ({
      count: state.voted ? state.count - 1 : state.count + 1,
      voted: !state.voted,
    })
  );

  async function vote() {
    setOptimistic('toggle');
    try {
      await fetch(`/api/features/${id}/vote`, { method: 'POST' });
    } catch {
      // оптимістичне оновлення автоматично откатиться при помилці
    }
  }

  return (
    <button
      onClick={vote}
      aria-pressed={optimisticState.voted}
      className={`flex flex-col items-center gap-0.5 w-14 py-2 rounded-lg border text-sm font-semibold transition-colors
        ${optimisticState.voted
          ? 'bg-blue-600 border-blue-600 text-white'
          : 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'
        }`}
    >
      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
        <path d="M8 3L14 10H2L8 3Z"/>
      </svg>
      <span>{optimisticState.count}</span>
    </button>
  );
}

export function FeatureList({ initialItems }: { initialItems: FeatureItem[] }) {
  const [items, setItems] = useState(initialItems);
  const [filter, setFilter] = useState<string>('all');

  const filtered = filter === 'all'
    ? items
    : items.filter(i => i.status === filter);

  return (
    <div className="max-w-2xl">
      {/* Фільтри по статусу */}
      <div className="flex gap-2 mb-6 flex-wrap">
        {(['all', 'proposed', 'planned', 'in_progress', 'done'] as const).map(s => (
          <button
            key={s}
            onClick={() => setFilter(s)}
            className={`px-3 py-1 rounded-full text-xs border transition-colors ${
              filter === s ? 'bg-gray-800 text-white border-gray-800' : 'border-gray-300'
            }`}
          >
            {s === 'all' ? 'Усі' : STATUS_LABELS[s].label}
          </button>
        ))}
      </div>

      <ul className="space-y-3">
        {filtered.map(item => (
          <li
            key={item.id}
            className="flex gap-4 p-4 rounded-xl border border-gray-200 hover:border-gray-300 transition-colors"
          >
            <VoteButton
              id={item.id}
              votesCount={item.votesCount}
              hasVoted={item.hasVoted}
            />
            <div className="flex-1 min-w-0">
              <div className="flex items-start gap-2 flex-wrap">
                <h3 className="font-medium text-gray-900">{item.title}</h3>
                <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_LABELS[item.status].color}`}>
                  {STATUS_LABELS[item.status].label}
                </span>
              </div>
              <p className="mt-1 text-sm text-gray-600 line-clamp-2">{item.description}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

Захист від накрутки

Для аутентифікованих користувачів — один голос по user_id. Для анонімних — fingerprint по IP + User-Agent + додаткова rate-limiting прошарка:

// middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';

export const voteLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 хвилин
  max: 30, // 30 голосів за 15 хвилин з одної IP
  message: { error: 'Занадто багато запитів' },
  standardHeaders: true,
  legacyHeaders: false,
});

Для більш серйозного захисту — Cloudflare Turnstile (CAPTCHA без challenge) при аномальній кількості голосів за короткий час.

Сповіщення підписникам

Коли статус запиту змінюється на planned або done, відправляємо email усім, хто голосував та вказав email:

async function notifyVoters(requestId: string, newStatus: string) {
  const votes = await prisma.featureVote.findMany({
    where: { requestId, user: { email: { not: null } } },
    include: { user: { select: { email: true } } },
  });

  const request = await prisma.featureRequest.findUniqueOrThrow({
    where: { id: requestId },
  });

  await mailer.sendBulk(
    votes.map(v => v.user.email!).filter(Boolean),
    {
      subject: `Оновлення по запиту: ${request.title}`,
      template: 'feature-status-update',
      data: { title: request.title, status: newStatus },
    }
  );
}

Часові рамки

Повна система з публічною дорожною картою, голосуванням, захистом від накрутки та email-повідомленнями — вісім-дванадцять днів. Без повідомлень та з голосуванням тільки для анонімних — чотири-п'ять днів.