feat: initial commit - VK Giveaway Randomizer Phase 1

This commit is contained in:
Ochenstarik 2026-08-17 23:34:49 +07:00
commit 02920a8743
41 changed files with 6967 additions and 0 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# PostgreSQL Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public"
# VK API Configuration
# Сервисный ключ доступа приложения VK (для публичных запросов)
VK_SERVICE_TOKEN="your_vk_service_token_here"
# ID приложения VK
VK_APP_ID="your_vk_app_id_here"
# Защищенный ключ приложения
VK_APP_SECRET="your_vk_app_secret_here"
# App Configuration
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NODE_ENV="development"

38
.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# Next.js build output
/.next/
/out/
# Production
/build
/dist
# Misc
.DS_Store
*.pem
# Local env files
.env
.env*.local
!.env.example
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Prisma
# prisma/migrations (keep migrations if needed, but not dev sqlite)

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# Randomayzer — VK Giveaway Randomizer (Этап 1)
Веб-приложение для проведения честных, прозрачных и доказуемых (Provably Fair) розыгрышей среди пользователей ВКонтакте (с архитектурным заделом под Telegram, YouTube и др.).
---
## 🎯 Возможности первого этапа
- **Парсинг и превью записей VK**: Поддержка любых ссылок на посты ВКонтакте (`vk.com/wall...`, `m.vk.com`, `vk.ru`, `?w=wall...`).
- **Сбор участников и фильтрация**:
- Лайки записи ❤️
- Комментарии (с дедупликацией: 1 пользователь = 1 шанс) 💬
- Репосты (с учетом настроек приватности профилей) 🔁
- Проверка подписки на сообщество-организатор 👥
- Исключение администраторов сообщества 🛡️
- Черный список ID и логинов.
- **Детерминированный Randomizer (Provably Fair)**:
- Исключен непрозрачный `Math.random()`.
- Выборка на основе HMAC-SHA256 и перетасовки Фишера-Йетса.
- Snapshot Hash (SHA-256) канонического списка участников + Seed = 100% повторяемость и верифицируемость.
- Поддержка основных и резервных призовых мест.
- **Интерактивный UI**:
- Dashboard со статистикой и списком кампаний.
- 5-шаговый визард создания розыгрыша.
- Живое превью условий и статуса допуска каждого участника с указанием причин отклонения.
- Презентация победителей и сертификат криптографического аудита.
---
## 🏗 Архитектура и стек технологий
- **Frontend / Backend**: Next.js 14+ (App Router), TypeScript, React, TailwindCSS, Lucide Icons.
- **Core Domain**: Независимый от соцсетей слой (`src/core/`) для жеребьевки, хеширования и фильтрации.
- **Social Providers**: Абстракция `SocialMediaProvider` (`src/providers/`) с клиентом VK API и встроенным `VkMockProvider` для изолированной разработки.
- **База данных**: PostgreSQL 16 + Prisma ORM (с in-memory fallback для быстрого локального запуска).
- **Тесты**: Vitest (юнит-тесты детерминированности, seed reproducibility, фильтров и парсера).
---
## 🚀 Инструкция по локальному запуску
### 1. Установка зависимостей
```bash
npm install
```
### 2. Настройка переменных окружения
Скопируйте файл конфигурации:
```bash
cp .env.example .env
```
По умолчанию приложение работает в автономном/mock-режиме без обязательного указания боевого ключа VK API.
Для работы с реальным VK API укажите в `.env`:
```env
VK_SERVICE_TOKEN="ваш_сервисный_ключ_vk"
```
### 3. Запуск базы данных (Docker Compose, опционально)
```bash
docker compose up -d
npm run prisma:push
```
*(Если Docker не запущен, приложение автоматически использует встроенный store)*.
### 4. Запуск тестов
```bash
npm test
```
### 5. Запуск сервера разработки
```bash
npm run dev
```
Откройте в браузере: [http://localhost:3000](http://localhost:3000)
---
## 🧪 Запуск автоматических тестов
В проекте реализованы unit-тесты ядра:
- `tests/randomizer.test.ts`: Тесты воспроизводимости seed, отсутствия дублей, выборки резерва и сторонней верификации `verifyDrawResult`.
- `tests/filter-engine.test.ts`: Тесты всех комбинаций условий отбора, дедупликации комментариев и черных списков.
- `tests/vk-parser.test.ts`: Тесты парсинга всех форматов ссылок VK.
Запуск:
```bash
npm run test
```
---
## 📚 Документация проекта
- [docs/VK_API_RESEARCH.md](docs/VK_API_RESEARCH.md) — Исследование официального VK API, лимитов, токенов и методов `execute`.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — Архитектура слоев, абстракция провайдеров и механизм Provably Fair.
- [docs/DATA_MODEL.md](docs/DATA_MODEL.md) — Модели данных Prisma и схемы связей.

18
docker-compose.yml Normal file
View file

@ -0,0 +1,18 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: randomayzer-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: randomayzer
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

59
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,59 @@
# Архитектура VK Giveaway Randomizer
## 1. Обзор архитектуры
Система построена по принципам чистой архитектуры (Clean Architecture / Hexagonal Architecture) с разделением на независимые слои:
```mermaid
graph TD
UI[Frontend UI: Next.js App Router] --> API[Next.js API Routes / Server Actions]
API --> Core[Core Domain Layer: Randomizer & Filtering]
API --> Providers[Social Providers Layer: VK / Telegram / YouTube]
API --> DB[(Database: PostgreSQL + Prisma)]
Providers --> VK[Official VK API / VK Mock]
```
## 2. Слои приложения
### 2.1. Core Domain Layer (`src/core/`)
Ядро приложения не зависит от конкретной социальной сети или веб-фреймворка:
- **`src/core/randomizer/`**:
- `deterministic.ts`: Реализация детерминированного генератора случайных чисел (PRNG) и перетасовки Фишера-Йетса с использованием криптографических хешей (SHA-256 HMAC).
- `hasher.ts`: Генерация фиксированного отпечатка (Snapshot Hash) списка участников перед розыгрышем.
- **`src/core/filtering/`**:
- `filter-engine.ts`: Механизм фильтрации участников на основе заданных условий (лайк, репост, коммент, подписка, черный список ID, исключение админов, дедупликация).
- **`src/core/types/`**:
- Типизированные контракты сущностей (`Giveaway`, `Participant`, `DrawResult`, `AuditRecord`, `FilterRules`).
### 2.2. Social Providers Layer (`src/providers/`)
Абстракция взаимодействия с внешними платформами:
- **`SocialMediaProvider`** (Interface):
- `fetchPost(url: string)`: Получение метаданных публикации (автор, текст, превью, счетчики).
- `fetchParticipants(params: FetchParticipantsParams)`: Загрузка списка участников (лайки, комменты, репосты).
- `checkSubscription(userIds: string[], groupId: string)`: Проверка подписки на сообщество.
- **`VkProvider`**: Боевой клиент к VK API с поддержкой пакетных запросов `execute`.
- **`VkMockProvider`**: Тестовый провайдер для демонстрации, локальной разработки и оффлайн-тестирования.
- **`ProviderRegistry`**: Фабрика для получения провайдера по типу платформы (`vk`, `telegram`, `youtube`).
### 2.3. Data & Persistence Layer (`prisma/` + `src/lib/`)
- **PostgreSQL** в качестве надежного реляционного хранилища.
- **Prisma ORM** для типобезопасной работы с БД.
- Хранение всех розыгрышей, снапшотов участников и записей аудита для публичной верификации.
### 2.4. Presentation Layer (`src/app/` + `src/components/`)
- Modern React + Next.js App Router.
- Интерактивный визард создания розыгрыша с живым превью поста, настройкой условий, интерактивной таблицей участников и презентацией победителей.
---
## 3. Механизм честности и доказуемости (Provably Fair)
Каждый розыгрыш формирует криптографический аудит-след:
1. **Снапшот участников**: Список прошедших фильтрацию (eligible) участников сортируется по `platformUserId` и хешируется через SHA-256:
$$\text{ParticipantsHash} = \text{SHA256}(\text{JSON}(\text{sortedEligibleParticipants}))$$
2. **Seed розыгрыша**: Пользовательский или сгенерированный криптографически стойкий seed.
3. **Детерминированный выбор**:
- Для каждого шага выбора индекса вычисляется:
$$\text{Hash}_i = \text{HMAC-SHA256}(\text{seed} + ":" + i, \text{ParticipantsHash})$$
- Индекс победителя определяется детерминированно из полученного хеша.
4. **Результат**: Зная `ParticipantsHash` и `seed`, любой внешний наблюдатель может воспроизвести выбор и убедиться в честности результата на 100%.

92
docs/DATA_MODEL.md Normal file
View file

@ -0,0 +1,92 @@
# Модель данных VK Giveaway Randomizer
## 1. Схема данных (Entity-Relationship)
```mermaid
erDiagram
Giveaway ||--o{ Participant : "has"
Giveaway ||--o| DrawResult : "produces"
Giveaway ||--o| AuditRecord : "verifies"
Giveaway {
string id PK
string platform "VK | TELEGRAM | YOUTUBE"
string sourceUrl
string platformOwnerId
string platformPostId
string title
string description
string postImageUrl
string status "DRAFT | FETCHING | READY | COMPLETED | CANCELLED"
json filterRules
int winnersCount
int reserveWinnersCount
string seed
datetime createdAt
datetime updatedAt
datetime drawnAt
}
Participant {
string id PK
string giveawayId FK
string platformUserId
string firstName
string lastName
string username
string avatarUrl
string source "LIKES | COMMENTS | REPOSTS | COMBINED"
boolean liked
boolean commented
int commentsCount
boolean reposted
boolean subscribed
boolean eligible
string exclusionReason
datetime createdAt
}
DrawResult {
string id PK
string giveawayId FK
json winners
json reserveWinners
int totalEligibleCount
int totalLoadedCount
string seedUsed
string algorithm
datetime drawnAt
}
AuditRecord {
string id PK
string giveawayId FK
string participantsSnapshotHash
string seed
string algorithm
json filterRulesSnapshot
json winnersSnapshot
datetime verifiedAt
string verificationSignature
}
```
## 2. Описание сущностей
### Giveaway (Розыгрыш)
- Главная сущность кампании розыгрыша.
- Хранит метаданные поста (ссылка, ID автора, ID поста, заголовок, превью-картинка), статус выполнения, настройки фильтрации и параметры выбора (кол-во победителей, seed).
### Participant (Участник)
- Запись об участнике, полученном из социальной сети.
- Хранит профиль пользователя (`platformUserId`, имя, аватарка) и флаги выполненных действий (`liked`, `commented`, `commentsCount`, `reposted`, `subscribed`).
- Поле `eligible: boolean` определяет, допущен ли участник к жеребьевке.
- Поле `exclusionReason: string` фиксирует точную причину недопуска (например, `"NOT_SUBSCRIBED"`, `"NO_REPOST"`, `"BLACKLISTED"`).
### DrawResult (Результат розыгрыша)
- Зафиксированный результат проведения розыгрыша.
- Хранит массив основных победителей, массив резервных победителей, время розыгрыша, алгоритм и использованный seed.
### AuditRecord (Запись аудита и доказуемости)
- Неизменяемый криптографический слепок розыгрыша.
- Содержит `participantsSnapshotHash` (SHA-256 хеш канонического списка участников), точный `seed`, снапшот правил фильтрации и результатов. Позволяет любому стороннему лицу проверить результат жеребьевки.

130
docs/VK_API_RESEARCH.md Normal file
View file

@ -0,0 +1,130 @@
# Исследование VK API для проведения розыгрышей
Данный документ содержит детальный анализ методов официального VK API, типов токенов, ограничений, прав доступа и лимитов для реализации сервиса розыгрышей.
---
## 1. Типы токенов и уровни доступа
| Тип токена | Как получается | Права / Scope | Применимость в розыгрышах | Ограничения |
|---|---|---|---|---|
| **Сервисный ключ доступа (Service Token)** | В настройках VK App (Standalone / Web) | Публичные данные (стены открытых групп, открытые профили) | Базовый сбор лайков и комментариев с открытых стен | Не может получать список репостов (`wall.getReposts`), не может проверять закрытые профили |
| **Ключ сообщества (Community / Group Token)** | В настройках группы (Управление -> Работа с API) | `wall`, `photos`, `messages`, `manage` | Идеален, когда розыгрыш проводит само сообщество-организатор | Работает только в рамках данного сообщества |
| **Пользовательский токен (User Token / OAuth)** | VK ID OAuth 2.0 (Implicit Flow / Authorization Code) | `wall`, `groups`, `friends`, `offline` | Максимальный доступ (проверка репостов, членства в закрытых группах) | Требует авторизации организатора через VK ID |
---
## 2. Ключевые методы VK API для розыгрыша
### 2.1. Получение информации о посте
- **Метод**: `wall.getById`
- **Параметры**:
- `posts`: строка формата `"{owner_id}_{post_id}"` (например, `-123456_7890`)
- `extended`: `1` (возвращает данные авторов и прикрепленных сообществ)
- **Токен**: Сервисный, Сообщества или Пользовательский.
- **Возвращаемые данные**: Текст записи, вложения (фотографии/видео), счетчики (`likes.count`, `comments.count`, `reposts.count`, `views.count`), дата публикации.
### 2.2. Сбор лайков
- **Метод**: `likes.getList`
- **Параметры**:
- `type`: `"post"`
- `owner_id`: ID владельца стены (отрицательный для групп)
- `item_id`: ID записи
- `filter`: `"likes"` (или `"copies"` для репостов)
- `extended`: `1` (возвращает имя, фамилию, аватарку)
- `count`: до `1000` за один запрос
- `offset`: смещение для пагинации
- **Токен**: Сервисный или Пользовательский.
- **Особенности**: Позволяет быстро выгрузить до 1000 пользователей за запрос. С помощью `execute` можно за один сетевой запрос выгрузить до 25 000 лайков.
### 2.3. Сбор комментариев
- **Метод**: `wall.getComments`
- **Параметры**:
- `owner_id`: ID сообщества / пользователя
- `post_id`: ID записи
- `need_likes`: `0` или `1`
- `extended`: `1` (возвращает профили комментаторов `profiles` и `groups`)
- `count`: до `100` за один запрос
- `offset`: смещение
- `fields`: `"photo_100,photo_200,screen_name,sex"`
- **Токен**: Сервисный или Пользовательский.
- **Особенности**: Лимит 100 комментариев на вызов. Для постов с тысячами комментариев необходима пакетная выгрузка через `execute`.
### 2.4. Сбор и проверка репостов
- **Метод 1**: `wall.getReposts`
- **Параметры**: `owner_id`, `post_id`, `count` (до 1000), `offset`
- **Ограничение**: Метод возвращает только репосты на открытые стены и доступен преимущественно с пользовательским токеном с правами `wall` или токеном сообщества.
- **Метод 2**: `likes.getList` с параметром `filter="copies"`
- Возвращает пользователей, сделавших репост записи (если их профили и настройки приватности позволяют отображать действие).
- **Спорные места и Privacy Policy VK**:
- Если профиль пользователя закрыт (приватный аккаунт), стороннее приложение **не может** увидеть репост на его стене без авторизации самого этого пользователя. В регламенте розыгрышей организаторы обычно указывают: *"На время розыгрыша страница участника должна быть открыта"*.
### 2.5. Проверка подписки на сообщество
- **Метод 1 (пакетная проверка)**: `groups.isMember`
- **Параметры**: `group_id`, `user_id` или `user_ids` (до 500 ID через запятую), `extended: 1`
- **Возвращает**: `member: 1/0`, `can_invite`, `can_post`.
- Высокая скорость: можно проверить сразу пачку из 500 участников.
- **Метод 2 (полный список подписчиков)**: `groups.getMembers`
- **Параметры**: `group_id`, `count` (до 1000), `offset`, `fields`
- Подходит для сверки базы подписчиков.
---
## 3. Оптимизация через `execute` (VK Script)
VK API предоставляет процедуру `execute`, позволяющую исполнять код на серверах VK (язык VKScript, подмножество JS/ActionScript).
- **Лимит**: До 25 вызовов API внутри одного `execute`.
- **Эффективность**:
- `likes.getList`: 25 * 1000 = **25 000 лайков за 1 сетевой HTTP-запрос**.
- `groups.isMember`: 25 * 500 = **12 500 проверок подписки за 1 запрос**.
- `wall.getComments`: 25 * 100 = **2 500 комментариев за 1 запрос**.
Пример VKScript для пакетного сбора лайков:
```javascript
var owner_id = Args.owner_id;
var item_id = Args.item_id;
var offset = parseInt(Args.offset);
var i = 0;
var all_items = [];
while (i < 25) {
var res = API.likes.getList({
"type": "post",
"owner_id": owner_id,
"item_id": item_id,
"count": 1000,
"offset": offset + (i * 1000),
"extended": 1
});
if (res.items.length == 0) {
return {"items": all_items, "count": res.count, "done": 1};
}
all_items = all_items + res.items;
i = i + 1;
}
return {"items": all_items, "offset": offset + (i * 1000), "done": 0};
```
---
## 4. Лимиты и Rate Limiting
- **Частота запросов**:
- Пользовательский токен: до 3 запросов в секунду.
- Сервисный ключ / токен сообщества: до 20 (в некоторых случаях до 50) запросов в секунду.
- При превышении возвращается `Error 6: Too many requests per second`.
- **Обработка ошибок**:
- `Error 15: Access denied` — закрытая группа/профиль.
- `Error 18: User was deleted or banned` — деактивированные аккаунты (собачки), которые автоматически должны исключаться фильтрами.
- `Error 29: Rate limit reached` — дневной лимит.
---
## 5. Выводы для Архитектуры приложения
1. **Многоуровневый сбор данных**:
- На этапе 1 создана модульная архитектура со слоем `SocialMediaProvider`.
- Реализован `VkMockProvider` для детерминированного тестирования и работы без ключей API, и каркас `VkProvider` с чистыми контрактами.
2. **Асинхронность и очереди**:
- Для масштабных розыгрышей (10 000+ участников) сбор данных должен происходить поэтапно (лайки -> комменты -> подписка) с индикатором прогресса в UI.
3. **Требование открытых профилей**:
- В UI и аудит-отчете необходимо явно фиксировать причину недопуска (`exclusionReason = "PRIVATE_PROFILE_OR_NO_REPOST"`, `"NOT_SUBSCRIBED"`, `"IS_ADMIN"`, `"DUPLICATE_COMMENT"`).

22
next.config.mjs Normal file
View file

@ -0,0 +1,22 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.userapi.com',
},
{
protocol: 'https',
hostname: '**.vk.com',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
};
export default nextConfig;

3146
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "randomayzer",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
"next": "^14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/node": "^20.16.11",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"prisma": "^5.20.0",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.3",
"vitest": "^2.1.3"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

106
prisma/schema.prisma Normal file
View file

@ -0,0 +1,106 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Platform {
VK
TELEGRAM
YOUTUBE
}
enum GiveawayStatus {
DRAFT
FETCHING
READY
COMPLETED
CANCELLED
}
enum ParticipantSource {
LIKES
COMMENTS
REPOSTS
COMBINED
}
model Giveaway {
id String @id @default(cuid())
platform Platform @default(VK)
sourceUrl String
platformOwnerId String
platformPostId String
title String
description String?
postImageUrl String?
postLikesCount Int @default(0)
postCommentsCount Int @default(0)
postRepostsCount Int @default(0)
status GiveawayStatus @default(DRAFT)
filterRules Json // FilterRules object
winnersCount Int @default(1)
reserveWinnersCount Int @default(0)
seed String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drawnAt DateTime?
participants Participant[]
drawResult DrawResult?
auditRecord AuditRecord?
@@index([platform, platformOwnerId, platformPostId])
}
model Participant {
id String @id @default(cuid())
giveawayId String
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
platformUserId String
firstName String
lastName String
username String?
avatarUrl String?
source ParticipantSource @default(LIKES)
liked Boolean @default(false)
commented Boolean @default(false)
commentsCount Int @default(0)
reposted Boolean @default(false)
subscribed Boolean @default(false)
eligible Boolean @default(true)
exclusionReason String?
createdAt DateTime @default(now())
@@unique([giveawayId, platformUserId])
@@index([giveawayId, eligible])
}
model DrawResult {
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
winners Json // Array of Winner entities
reserveWinners Json // Array of Winner entities
totalEligibleCount Int
totalLoadedCount Int
seedUsed String
algorithm String @default("HMAC-SHA256-FISHER-YATES")
drawnAt DateTime @default(now())
}
model AuditRecord {
id String @id @default(cuid())
giveawayId String @unique
giveaway Giveaway @relation(fields: [giveawayId], references: [id], onDelete: Cascade)
participantsSnapshotHash String
seed String
algorithm String @default("HMAC-SHA256-FISHER-YATES")
filterRulesSnapshot Json
winnersSnapshot Json
verifiedAt DateTime @default(now())
verificationSignature String
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { executeDeterministicDraw } from '@/core/randomizer/deterministic';
import { generateRandomSeed } from '@/core/randomizer/hasher';
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const body = await req.json().catch(() => ({}));
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
}
const eligibleParticipants = giveaway.participants.filter(p => p.eligible);
if (eligibleParticipants.length === 0) {
return NextResponse.json({
error: 'Нет допущенных участников для проведения розыгрыша'
}, { status: 400 });
}
const winnersCount = body.winnersCount || giveaway.winnersCount || 1;
const reserveWinnersCount = body.reserveWinnersCount ?? giveaway.reserveWinnersCount ?? 0;
const seed = body.seed?.trim() || giveaway.seed || generateRandomSeed();
// Execute provably fair draw
const drawResult = executeDeterministicDraw({
giveawayId: id,
eligibleParticipants,
totalLoadedCount: giveaway.participants.length,
winnersCount,
reserveWinnersCount,
seed,
filterRules: giveaway.filterRules,
});
// Persist result
const updatedGiveaway = await GiveawayStore.saveDrawResult(id, drawResult);
return NextResponse.json({
success: true,
drawResult,
giveaway: updatedGiveaway,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { ProviderRegistry } from '@/providers/registry';
import { applyFilterRules } from '@/core/filtering/filter-engine';
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const body = await req.json().catch(() => ({}));
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
}
const rules = body.filterRules || giveaway.filterRules;
const provider = ProviderRegistry.getProvider(giveaway.platform);
// Fetch raw participants from provider
const rawParticipants = await provider.fetchParticipants({
ownerId: giveaway.platformOwnerId,
postId: giveaway.platformPostId,
sourceUrl: giveaway.sourceUrl,
includeLikes: true,
includeComments: rules.requireComment,
includeReposts: rules.requireRepost,
checkSubscription: rules.requireSubscription,
});
// Apply filtering engine
const filterResult = applyFilterRules(rawParticipants, rules);
// Update participants in store
await GiveawayStore.updateParticipants(id, filterResult.allParticipants);
return NextResponse.json({
success: true,
stats: filterResult.stats,
allParticipants: filterResult.allParticipants,
eligibleCount: filterResult.eligibleParticipants.length,
excludedCount: filterResult.excludedParticipants.length,
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
const giveaway = await GiveawayStore.getById(id);
if (!giveaway) {
return NextResponse.json({ error: 'Giveaway not found' }, { status: 404 });
}
return NextResponse.json({ success: true, giveaway });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { GiveawayStore } from '@/lib/giveaway-store';
import { DEFAULT_FILTER_RULES } from '@/core/types/giveaway';
export async function GET() {
try {
const list = await GiveawayStore.listAll();
return NextResponse.json({ success: true, giveaways: list });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { sourceUrl, post, filterRules = DEFAULT_FILTER_RULES, winnersCount = 1, reserveWinnersCount = 0, seed } = body;
if (!sourceUrl || !post) {
return NextResponse.json({ error: 'sourceUrl and post are required' }, { status: 400 });
}
const giveaway = await GiveawayStore.create({
sourceUrl,
post,
filterRules,
winnersCount,
reserveWinnersCount,
seed,
});
return NextResponse.json({ success: true, giveaway });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View file

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { ProviderRegistry } from '@/providers/registry';
import { PlatformType } from '@/core/types/giveaway';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { url, platform = 'VK' } = body;
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
const provider = ProviderRegistry.getProvider(platform as PlatformType);
const parsed = provider.parsePostUrl(url);
if (!parsed) {
return NextResponse.json({
error: 'Неверный формат ссылки на запись VK. Пример: https://vk.com/wall-123456_789'
}, { status: 400 });
}
const postMetadata = await provider.fetchPost(url);
return NextResponse.json({ success: true, post: postMetadata });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Ошибка при загрузке данных поста' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,214 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import {
ArrowLeft,
ShieldCheck,
Trophy,
ExternalLink,
CheckCircle2,
Copy,
Check,
Users,
Calendar,
Sparkles,
RefreshCw
} from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store';
export default function GiveawayDetailPage() {
const params = useParams();
const id = params?.id as string;
const [giveaway, setGiveaway] = useState<StoredGiveaway | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!id) return;
const fetchGw = async () => {
try {
setLoading(true);
const res = await fetch(`/api/giveaways/${id}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Розыгрыш не найден');
setGiveaway(data.giveaway);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchGw();
}, [id]);
if (loading) {
return (
<div className="py-20 text-center text-slate-400 text-sm">
<RefreshCw className="w-8 h-8 animate-spin mx-auto mb-3 text-blue-500" />
Загрузка данных розыгрыша...
</div>
);
}
if (error || !giveaway) {
return (
<div className="p-8 text-center bg-slate-900/60 border border-slate-800 rounded-2xl">
<p className="text-rose-400 mb-4">{error || 'Розыгрыш не найден'}</p>
<Link href="/" className="text-xs text-blue-400 hover:underline">
Вернуться на главную
</Link>
</div>
);
}
const drawResult = giveaway.drawResult;
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Top Header */}
<div className="flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Назад к списку
</Link>
<div className="flex items-center gap-2">
<span className="text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-medium">
VKontakte
</span>
{giveaway.status === 'COMPLETED' ? (
<span className="text-xs px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-medium">
Завершен
</span>
) : (
<span className="text-xs px-2.5 py-1 rounded-full bg-amber-500/10 text-amber-400 border border-amber-500/20 font-medium">
В процессе
</span>
)}
</div>
</div>
{/* Main Details Card */}
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div>
<h1 className="text-2xl font-bold text-white mb-2">{giveaway.title}</h1>
<p className="text-xs sm:text-sm text-slate-300 whitespace-pre-line leading-relaxed">
{giveaway.description}
</p>
</div>
{giveaway.postImageUrl && (
<div className="rounded-xl overflow-hidden max-h-64 border border-slate-800">
<img src={giveaway.postImageUrl} alt="" className="w-full h-full object-cover" />
</div>
)}
{/* Source link */}
<div className="flex items-center justify-between p-3 bg-slate-950 rounded-xl border border-slate-800 text-xs">
<span className="text-slate-400">Ссылка на запись:</span>
<a
href={giveaway.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline flex items-center gap-1 font-mono"
>
{giveaway.sourceUrl}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
{/* Draw Result Presentation */}
{drawResult && (
<div className="bg-gradient-to-br from-amber-500/10 via-slate-900/80 to-slate-950 border border-amber-500/30 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<div className="flex items-center gap-2 text-amber-400 font-bold text-base">
<Trophy className="w-5 h-5" />
Официальные победители
</div>
<div className="text-xs text-slate-400 flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
{new Date(drawResult.drawnAt).toLocaleString('ru-RU')}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{drawResult.winners.map((winner) => (
<div
key={winner.participant.platformUserId}
className="p-4 rounded-xl bg-slate-950 border border-amber-500/30 flex items-center gap-3.5"
>
<div className="w-12 h-12 rounded-full bg-slate-800 border-2 border-amber-400 flex items-center justify-center overflow-hidden shrink-0">
{winner.participant.avatarUrl ? (
<img src={winner.participant.avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-white font-bold">{winner.participant.firstName[0]}</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-amber-400">#{winner.position} место</span>
</div>
<h4 className="text-sm font-bold text-white truncate">
{winner.participant.firstName} {winner.participant.lastName}
</h4>
<a
href={`https://vk.com/id${winner.participant.platformUserId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:underline mt-0.5"
>
id{winner.participant.platformUserId}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
))}
</div>
{/* Provably Fair Audit Trail */}
<div className="pt-4 border-t border-slate-800 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-emerald-400 text-xs font-semibold">
<ShieldCheck className="w-4 h-4" />
Публичный криптографический аудит (Provably Fair)
</div>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(drawResult, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="text-xs text-slate-400 hover:text-white flex items-center gap-1"
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copied ? 'Скопировано' : 'JSON аудита'}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Seed розыгрыша:</span>
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800">
<span className="text-slate-400">Snapshot Hash (SHA-256):</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 sm:col-span-2">
<span className="text-slate-400">Verification Signature:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,904 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Sparkles,
Heart,
MessageSquare,
Repeat2,
Users,
Shield,
CheckCircle2,
XCircle,
Trophy,
RefreshCw,
Shuffle,
Copy,
ExternalLink,
Info,
Check,
Award,
AlertCircle
} from 'lucide-react';
import { FilterRules, DEFAULT_FILTER_RULES, PostMetadata } from '@/core/types/giveaway';
import { FilteredParticipant, Winner } from '@/core/types/participant';
import { DrawExecutionResult } from '@/core/types/audit';
export default function NewGiveawayWizardPage() {
const router = useRouter();
// Wizard state
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
// Step 1: Post URL & Metadata
const [postUrl, setPostUrl] = useState('');
const [loadingPost, setLoadingPost] = useState(false);
const [postError, setPostError] = useState<string | null>(null);
const [postData, setPostData] = useState<PostMetadata | null>(null);
const [createdGiveawayId, setCreatedGiveawayId] = useState<string | null>(null);
// Step 2: Conditions
const [rules, setRules] = useState<FilterRules>({ ...DEFAULT_FILTER_RULES });
const [blacklistInput, setBlacklistInput] = useState('');
// Step 3: Participants
const [loadingParticipants, setLoadingParticipants] = useState(false);
const [participants, setParticipants] = useState<FilteredParticipant[]>([]);
const [participantTab, setParticipantTab] = useState<'all' | 'eligible' | 'excluded'>('eligible');
// Step 4: Draw parameters
const [winnersCount, setWinnersCount] = useState<number>(1);
const [reserveWinnersCount, setReserveWinnersCount] = useState<number>(1);
const [seed, setSeed] = useState<string>('');
const [drawing, setDrawing] = useState(false);
// Step 5: Results
const [drawResult, setDrawResult] = useState<DrawExecutionResult | null>(null);
const [copiedProof, setCopiedProof] = useState(false);
// Step 1 handler: Fetch Post Metadata
const handleFetchPost = async () => {
if (!postUrl.trim()) return;
setLoadingPost(true);
setPostError(null);
try {
const res = await fetch('/api/posts/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: postUrl.trim(), platform: 'VK' }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || 'Не удалось загрузить данные поста');
}
setPostData(data.post);
// Create initial draft giveaway
const createRes = await fetch('/api/giveaways', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceUrl: postUrl.trim(),
post: data.post,
filterRules: rules,
}),
});
const createData = await createRes.json();
if (createData.giveaway) {
setCreatedGiveawayId(createData.giveaway.id);
}
} catch (err: any) {
setPostError(err.message);
} finally {
setLoadingPost(false);
}
};
// Step 2 handler: Fetch Participants
const handleFetchParticipants = async () => {
if (!createdGiveawayId) return;
setLoadingParticipants(true);
try {
const activeRules: FilterRules = {
...rules,
excludeBlacklistedIds: blacklistInput
.split(/[\n,]/)
.map(s => s.trim())
.filter(Boolean),
};
const res = await fetch(`/api/giveaways/${createdGiveawayId}/participants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filterRules: activeRules }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки участников');
setParticipants(data.allParticipants || []);
setStep(3);
} catch (err: any) {
alert(err.message);
} finally {
setLoadingParticipants(false);
}
};
// Step 4 handler: Execute Draw
const handleExecuteDraw = async () => {
if (!createdGiveawayId) return;
setDrawing(true);
try {
const res = await fetch(`/api/giveaways/${createdGiveawayId}/draw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
winnersCount,
reserveWinnersCount,
seed: seed.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка проведения розыгрыша');
setDrawResult(data.drawResult);
setStep(5);
} catch (err: any) {
alert(err.message);
} finally {
setDrawing(false);
}
};
const eligibleParticipants = participants.filter(p => p.eligible);
const excludedParticipants = participants.filter(p => !p.eligible);
const displayedParticipants =
participantTab === 'all'
? participants
: participantTab === 'eligible'
? eligibleParticipants
: excludedParticipants;
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Top Breadcrumb & Step Tracker */}
<div className="flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Вернуться на дашборд
</Link>
<span className="text-xs text-slate-400">Этап {step} из 5</span>
</div>
{/* Progress Steps Header */}
<div className="grid grid-cols-5 gap-2 p-1.5 bg-slate-900/80 border border-slate-800 rounded-xl text-center text-xs font-medium">
<div className={`py-2 rounded-lg transition-colors ${step >= 1 ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-slate-400'}`}>
1. Пост VK
</div>
<div className={`py-2 rounded-lg transition-colors ${step >= 2 ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-slate-400'}`}>
2. Условия
</div>
<div className={`py-2 rounded-lg transition-colors ${step >= 3 ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-slate-400'}`}>
3. Участники
</div>
<div className={`py-2 rounded-lg transition-colors ${step >= 4 ? 'bg-blue-600/20 text-blue-400 border border-blue-500/30' : 'text-slate-400'}`}>
4. Настройки
</div>
<div className={`py-2 rounded-lg transition-colors ${step >= 5 ? 'bg-emerald-600/20 text-emerald-400 border border-emerald-500/30' : 'text-slate-400'}`}>
5. Итоги
</div>
</div>
{/* ================= STEP 1: Post URL Input & Preview ================= */}
{step === 1 && (
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 1: Выберите запись ВКонтакте</h2>
<p className="text-xs sm:text-sm text-slate-400">
Вставьте ссылку на конкурсный пост со стены сообщества или личной страницы
</p>
</div>
<div className="space-y-3">
<label className="block text-xs font-medium text-slate-300">
Ссылка на пост ВКонтакте
</label>
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
placeholder="https://vk.com/wall-22446688_1054"
value={postUrl}
onChange={(e) => setPostUrl(e.target.value)}
className="flex-1 px-4 py-3 bg-slate-950 border border-slate-800 rounded-xl text-white text-sm focus:outline-none focus:border-blue-500 transition-colors"
/>
<button
onClick={handleFetchPost}
disabled={loadingPost || !postUrl.trim()}
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/25 flex items-center justify-center gap-2 shrink-0"
>
{loadingPost ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Загрузка...
</>
) : (
'Загрузить пост'
)}
</button>
</div>
{/* Quick Demo Helper */}
<div className="flex items-center gap-2 text-xs text-slate-400">
<Info className="w-3.5 h-3.5 text-blue-400" />
<span>Для теста можно вставить: </span>
<button
type="button"
onClick={() => setPostUrl('https://vk.com/wall-22446688_1054')}
className="text-blue-400 hover:underline"
>
https://vk.com/wall-22446688_1054
</button>
</div>
</div>
{postError && (
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs flex items-center gap-3">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{postError}</span>
</div>
)}
{/* Post Preview Card */}
{postData && (
<div className="p-5 rounded-xl bg-slate-950 border border-blue-500/30 space-y-4">
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
<div className="flex items-center gap-3">
{postData.authorAvatarUrl && (
<img
src={postData.authorAvatarUrl}
alt={postData.authorName || 'Author'}
className="w-10 h-10 rounded-full object-cover border border-slate-700"
/>
)}
<div>
<h4 className="text-sm font-semibold text-white">{postData.authorName}</h4>
<span className="text-xs text-slate-400">Сообщество организатора</span>
</div>
</div>
<span className="text-xs px-2.5 py-1 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20">
Пост готов
</span>
</div>
<p className="text-xs sm:text-sm text-slate-300 whitespace-pre-line leading-relaxed">
{postData.text}
</p>
{postData.imageUrl && (
<div className="relative rounded-xl overflow-hidden max-h-64 border border-slate-800">
<img
src={postData.imageUrl}
alt="Post preview"
className="w-full h-full object-cover"
/>
</div>
)}
{/* Counters */}
<div className="grid grid-cols-3 gap-3 pt-2">
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center">
<div className="flex items-center justify-center gap-1.5 text-rose-400 text-xs font-medium mb-1">
<Heart className="w-3.5 h-3.5 fill-rose-400/20" />
Лайки
</div>
<span className="text-lg font-bold text-white">{postData.likesCount}</span>
</div>
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center">
<div className="flex items-center justify-center gap-1.5 text-blue-400 text-xs font-medium mb-1">
<MessageSquare className="w-3.5 h-3.5" />
Комментарии
</div>
<span className="text-lg font-bold text-white">{postData.commentsCount}</span>
</div>
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center">
<div className="flex items-center justify-center gap-1.5 text-emerald-400 text-xs font-medium mb-1">
<Repeat2 className="w-3.5 h-3.5" />
Репосты
</div>
<span className="text-lg font-bold text-white">{postData.repostsCount}</span>
</div>
</div>
<div className="pt-2 flex justify-end">
<button
onClick={() => setStep(2)}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30"
>
Перейти к настройке условий
</button>
</div>
</div>
)}
</div>
)}
{/* ================= STEP 2: Conditions / Filter Rules ================= */}
{step === 2 && (
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 2: Условия участия</h2>
<p className="text-xs sm:text-sm text-slate-400">
Отметьте действия, которые участники должны выполнить для участия в розыгрыше
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Condition: Like */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.requireLike}
onChange={(e) => setRules({ ...rules, requireLike: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Heart className="w-4 h-4 text-rose-400" />
Поставил лайк
</div>
<p className="text-xs text-slate-400 mt-0.5">
Учитывать только тех, кто оценил запись
</p>
</div>
</label>
{/* Condition: Comment */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.requireComment}
onChange={(e) => setRules({ ...rules, requireComment: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-blue-400" />
Оставил комментарий
</div>
<p className="text-xs text-slate-400 mt-0.5">
Требовать наличие как минимум 1 комментария
</p>
</div>
</label>
{/* Condition: Repost */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.requireRepost}
onChange={(e) => setRules({ ...rules, requireRepost: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Repeat2 className="w-4 h-4 text-emerald-400" />
Сделал репост
</div>
<p className="text-xs text-slate-400 mt-0.5">
Проверять репост записи на открытую стену
</p>
</div>
</label>
{/* Condition: Subscription */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.requireSubscription}
onChange={(e) => setRules({ ...rules, requireSubscription: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Users className="w-4 h-4 text-indigo-400" />
Подписка на сообщество
</div>
<p className="text-xs text-slate-400 mt-0.5">
Проверять членство в группе организатора
</p>
</div>
</label>
{/* Filter: Exclude Admins */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.excludeAdmins}
onChange={(e) => setRules({ ...rules, excludeAdmins: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Shield className="w-4 h-4 text-amber-400" />
Исключить администраторов
</div>
<p className="text-xs text-slate-400 mt-0.5">
Не допускать к победе руководителей и контакты сообщества
</p>
</div>
</label>
{/* Filter: 1 User = 1 Chance */}
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
<input
type="checkbox"
checked={rules.excludeDuplicateComments}
onChange={(e) => setRules({ ...rules, excludeDuplicateComments: e.target.checked })}
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
/>
<div>
<div className="text-sm font-semibold text-white flex items-center gap-2">
<Sparkles className="w-4 h-4 text-purple-400" />
Учитывать пользователя один раз
</div>
<p className="text-xs text-slate-400 mt-0.5">
Дублирующие комментарии не увеличивают шансы
</p>
</div>
</label>
</div>
{/* Blacklist IDs */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-slate-300">
Черный список (VK ID или логины через запятую / с новой строки)
</label>
<textarea
rows={2}
placeholder="1000137, @spammer_user"
value={blacklistInput}
onChange={(e) => setBlacklistInput(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-950 border border-slate-800 rounded-xl text-white text-xs focus:outline-none focus:border-blue-500 transition-colors"
/>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-800">
<button
onClick={() => setStep(1)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
Назад
</button>
<button
onClick={handleFetchParticipants}
disabled={loadingParticipants}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30 flex items-center gap-2"
>
{loadingParticipants ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Загрузка участников...
</>
) : (
'Загрузить и отфильтровать участников →'
)}
</button>
</div>
</div>
)}
{/* ================= STEP 3: Participants Table ================= */}
{step === 3 && (
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 3: Проверка участников</h2>
<p className="text-xs sm:text-sm text-slate-400">
Список загруженных пользователей и проверка выполнения условий
</p>
</div>
{/* Filter Tabs */}
<div className="flex items-center gap-1.5 p-1 bg-slate-950 border border-slate-800 rounded-lg text-xs font-medium self-start sm:self-auto">
<button
onClick={() => setParticipantTab('eligible')}
className={`px-3 py-1.5 rounded-md transition-colors ${
participantTab === 'eligible'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
Допущены ({eligibleParticipants.length})
</button>
<button
onClick={() => setParticipantTab('excluded')}
className={`px-3 py-1.5 rounded-md transition-colors ${
participantTab === 'excluded'
? 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
Отклонены ({excludedParticipants.length})
</button>
<button
onClick={() => setParticipantTab('all')}
className={`px-3 py-1.5 rounded-md transition-colors ${
participantTab === 'all'
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
Все ({participants.length})
</button>
</div>
</div>
{/* Table Container */}
<div className="border border-slate-800 rounded-xl overflow-hidden max-h-96 overflow-y-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-950 text-slate-400 sticky top-0 border-b border-slate-800">
<tr>
<th className="py-3 px-4">Участник</th>
<th className="py-3 px-4">VK ID</th>
<th className="py-3 px-4 text-center">Лайк</th>
<th className="py-3 px-4 text-center">Коммент</th>
<th className="py-3 px-4 text-center">Репост</th>
<th className="py-3 px-4 text-center">Подписка</th>
<th className="py-3 px-4 text-right">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 bg-slate-900/40">
{displayedParticipants.map((p) => (
<tr key={p.platformUserId} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3 px-4 flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center overflow-hidden shrink-0 text-slate-300 font-medium">
{p.avatarUrl ? (
<img src={p.avatarUrl} alt="" className="w-full h-full object-cover" />
) : (
p.firstName[0]
)}
</div>
<span className="font-medium text-white truncate max-w-[140px]">
{p.firstName} {p.lastName}
</span>
</td>
<td className="py-3 px-4 text-slate-400 font-mono">
id{p.platformUserId}
</td>
<td className="py-3 px-4 text-center">
{p.liked ? (
<Check className="w-4 h-4 text-rose-400 mx-auto" />
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-center">
{p.commented ? (
<span className="text-blue-400 font-medium">{p.commentsCount || 1}</span>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-center">
{p.reposted ? (
<Check className="w-4 h-4 text-emerald-400 mx-auto" />
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-center">
{p.subscribed ? (
<Check className="w-4 h-4 text-indigo-400 mx-auto" />
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="py-3 px-4 text-right">
{p.eligible ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" />
Допущен
</span>
) : (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20 cursor-help"
title={p.exclusionReason || 'Не выполнил условия'}
>
<XCircle className="w-3 h-3" />
{p.exclusionReason || 'Отклонен'}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-800">
<button
onClick={() => setStep(2)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
Назад к условиям
</button>
<button
onClick={() => setStep(4)}
disabled={eligibleParticipants.length === 0}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-all shadow-md shadow-blue-600/30"
>
Перейти к розыгрышу ({eligibleParticipants.length} допущено)
</button>
</div>
</div>
)}
{/* ================= STEP 4: Draw Configuration ================= */}
{step === 4 && (
<div className="bg-slate-900/70 border border-slate-800 rounded-2xl p-6 sm:p-8 space-y-6 shadow-xl">
<div>
<h2 className="text-xl font-bold text-white mb-1">Шаг 4: Настройки жеребьевки</h2>
<p className="text-xs sm:text-sm text-slate-400">
Укажите количество победителей и параметры seed для криптографического выбора
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Winners Count */}
<div className="space-y-2 p-4 bg-slate-950 border border-slate-800 rounded-xl">
<label className="block text-xs font-semibold text-slate-300">
Количество основных победителей
</label>
<input
type="number"
min={1}
max={Math.max(1, eligibleParticipants.length)}
value={winnersCount}
onChange={(e) => setWinnersCount(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-bold text-base focus:outline-none focus:border-blue-500"
/>
<p className="text-[11px] text-slate-400">Призовых мест</p>
</div>
{/* Reserve Winners Count */}
<div className="space-y-2 p-4 bg-slate-950 border border-slate-800 rounded-xl">
<label className="block text-xs font-semibold text-slate-300">
Количество резервных победителей
</label>
<input
type="number"
min={0}
max={Math.max(0, eligibleParticipants.length - winnersCount)}
value={reserveWinnersCount}
onChange={(e) => setReserveWinnersCount(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-bold text-base focus:outline-none focus:border-blue-500"
/>
<p className="text-[11px] text-slate-400">На случай невыхода на связь</p>
</div>
</div>
{/* Seed configuration */}
<div className="p-4 bg-slate-950 border border-slate-800 rounded-xl space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold text-slate-300 flex items-center gap-1.5">
<Shuffle className="w-3.5 h-3.5 text-blue-400" />
Seed розыгрыша (опционально)
</label>
<span className="text-[10px] text-slate-400">Для предварительной публикации</span>
</div>
<input
type="text"
placeholder="Оставьте пустым для автогенерации крипто-seed"
value={seed}
onChange={(e) => setSeed(e.target.value)}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-blue-500"
/>
<p className="text-[11px] text-slate-400">
Если seed не указан, система сгенерирует случайный крипто-ключ в момент запуска
</p>
</div>
<div className="flex justify-between items-center pt-4 border-t border-slate-800">
<button
onClick={() => setStep(3)}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition-colors"
>
Назад к участникам
</button>
<button
onClick={handleExecuteDraw}
disabled={drawing || eligibleParticipants.length === 0}
className="px-8 py-3.5 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-blue-600/30 flex items-center gap-2 active:scale-95"
>
{drawing ? (
<>
<RefreshCw className="w-5 h-5 animate-spin" />
Определение победителей...
</>
) : (
<>
<Trophy className="w-5 h-5 text-amber-300" />
Определить победителя!
</>
)}
</button>
</div>
</div>
)}
{/* ================= STEP 5: Results & Provably Fair Audit ================= */}
{step === 5 && drawResult && (
<div className="space-y-6">
{/* Winner Celebration Banner */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-amber-500/20 via-blue-900/40 to-slate-950 border border-amber-500/30 p-8 shadow-2xl text-center">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-amber-500/20 border border-amber-400/30 text-amber-300 text-xs font-bold mb-3">
<Trophy className="w-4 h-4" />
Розыгрыш успешно проведен!
</div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-white mb-2">
🎉 Поздравляем победителей!
</h2>
<p className="text-xs sm:text-sm text-slate-300 max-w-lg mx-auto mb-6">
Выборка произведена детерминированным алгоритмом HMAC-SHA256 среди {drawResult.totalEligibleCount} допущенных участников.
</p>
{/* Main Winners Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto text-left">
{drawResult.winners.map((winner) => (
<div
key={winner.participant.platformUserId}
className="p-5 rounded-2xl bg-slate-900/90 border-2 border-amber-500/40 shadow-xl flex items-center gap-4 relative overflow-hidden"
>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-amber-500/20 text-amber-300 border border-amber-500/30 font-extrabold text-xs">
#{winner.position} место
</div>
<div className="w-14 h-14 rounded-full bg-slate-800 border-2 border-amber-400 flex items-center justify-center overflow-hidden shrink-0">
{winner.participant.avatarUrl ? (
<img
src={winner.participant.avatarUrl}
alt=""
className="w-full h-full object-cover"
/>
) : (
<span className="text-xl font-bold text-white">
{winner.participant.firstName[0]}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-base font-bold text-white truncate">
{winner.participant.firstName} {winner.participant.lastName}
</h4>
<span className="text-xs text-slate-400 font-mono block">
id{winner.participant.platformUserId}
</span>
<a
href={`https://vk.com/id${winner.participant.platformUserId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 mt-1"
>
Открыть профиль VK
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
))}
</div>
{/* Reserve Winners (if any) */}
{drawResult.reserveWinners.length > 0 && (
<div className="mt-6 pt-6 border-t border-slate-800 max-w-2xl mx-auto text-left">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">
Резервные победители:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{drawResult.reserveWinners.map((res) => (
<div
key={res.participant.platformUserId}
className="p-3 rounded-xl bg-slate-900/60 border border-slate-800 flex items-center gap-3"
>
<span className="text-xs font-bold text-slate-400">
Резерв #{res.position}
</span>
<span className="text-xs font-medium text-white truncate">
{res.participant.firstName} {res.participant.lastName}
</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Provably Fair Audit Certificate */}
<div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-emerald-400 text-sm font-semibold">
<Shield className="w-4 h-4" />
Криптографический сертификат честности (Audit Trail)
</div>
<button
onClick={() => {
const proofText = `Розыгрыш Randomayzer
Giveaway ID: ${drawResult.giveawayId}
Seed: ${drawResult.seedUsed}
Snapshot Hash (SHA-256): ${drawResult.participantsSnapshotHash}
Verification Signature: ${drawResult.verificationSignature}
Алгоритм: ${drawResult.algorithm}
Дата: ${drawResult.drawnAt}
Победители: ${drawResult.winners.map(w => `${w.participant.firstName} ${w.participant.lastName} (id${w.participant.platformUserId})`).join(', ')}`;
navigator.clipboard.writeText(proofText);
setCopiedProof(true);
setTimeout(() => setCopiedProof(false), 2000);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-medium transition-colors"
>
{copiedProof ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
Скопировано!
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
Скопировать протокол
</>
)}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
<span className="text-slate-400 font-medium">Seed розыгрыша:</span>
<p className="font-mono text-blue-400 break-all">{drawResult.seedUsed}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1">
<span className="text-slate-400 font-medium">Хеш участников (SHA-256):</span>
<p className="font-mono text-emerald-400 break-all">{drawResult.participantsSnapshotHash}</p>
</div>
<div className="p-3 bg-slate-950 rounded-xl border border-slate-800 space-y-1 sm:col-span-2">
<span className="text-slate-400 font-medium">Цифровая подпись верификации:</span>
<p className="font-mono text-indigo-300 break-all">{drawResult.verificationSignature}</p>
</div>
</div>
<div className="flex justify-between items-center pt-2">
<Link
href="/"
className="text-xs font-medium text-blue-400 hover:underline"
>
Вернуться в дашборд
</Link>
<Link
href={`/giveaways/${drawResult.giveawayId}`}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-medium transition-colors"
>
Страница постоянного аудита
</Link>
</div>
</div>
</div>
)}
</div>
);
}

49
src/app/globals.css Normal file
View file

@ -0,0 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0b0f17;
--foreground: #f1f5f9;
--card-bg: #131b2e;
--card-border: #1e293b;
--primary: #0077ff;
--primary-hover: #0066dd;
--accent-gold: #f59e0b;
}
body {
color: var(--foreground);
background: var(--background);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
}
/* Custom smooth transitions */
.transition-card {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-card:hover {
transform: translateY(-2px);
border-color: #3b82f6;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}

75
src/app/layout.tsx Normal file
View file

@ -0,0 +1,75 @@
import type { Metadata } from 'next';
import './globals.css';
import Link from 'next/link';
import { Gift, ShieldCheck, PlusCircle, LayoutDashboard } from 'lucide-react';
export const metadata: Metadata = {
title: 'Randomayzer — Доказуемые розыгрыши ВКонтакте',
description: 'Прозрачный и верифицируемый сервис проведения конкурсов и розыгрышей в социальных сетях',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className="bg-[#0b0f17] text-slate-100 min-h-screen flex flex-col antialiased selection:bg-blue-600 selection:text-white">
{/* Navigation Header */}
<header className="border-b border-slate-800 bg-[#0f172a]/90 backdrop-blur sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-105 transition-transform">
<Gift className="w-5 h-5 text-white" />
</div>
<div>
<span className="font-bold text-lg tracking-tight bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
Randomayzer
</span>
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20">
VK Edition
</span>
</div>
</Link>
<nav className="flex items-center gap-3">
<Link
href="/"
className="flex items-center gap-2 px-3.5 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800/60 rounded-lg transition-colors"
>
<LayoutDashboard className="w-4 h-4" />
Дашборд
</Link>
<Link
href="/giveaways/new"
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-all shadow-md shadow-blue-600/25 active:scale-95"
>
<PlusCircle className="w-4 h-4" />
Новый розыгрыш
</Link>
</nav>
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 py-8">
{children}
</main>
{/* Footer */}
<footer className="border-t border-slate-800 bg-[#0f172a] py-6 text-sm text-slate-400">
<div className="max-w-6xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-slate-400">
<ShieldCheck className="w-4 h-4 text-emerald-400" />
<span>Provably Fair Engine Криптографически доказуемый выбор</span>
</div>
<p className="text-xs text-slate-400">
Randomayzer Core v1.0 Этап 1
</p>
</div>
</footer>
</body>
</html>
);
}

212
src/app/page.tsx Normal file
View file

@ -0,0 +1,212 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import {
Gift,
PlusCircle,
Sparkles,
CheckCircle2,
Clock,
Users,
ShieldCheck,
ArrowRight,
RefreshCw,
ExternalLink
} from 'lucide-react';
import { StoredGiveaway } from '@/lib/giveaway-store';
export default function DashboardPage() {
const [giveaways, setGiveaways] = useState<StoredGiveaway[]>([]);
const [loading, setLoading] = useState(true);
const fetchGiveaways = async () => {
try {
setLoading(true);
const res = await fetch('/api/giveaways');
const data = await res.json();
if (data.giveaways) {
setGiveaways(data.giveaways);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchGiveaways();
}, []);
const completedCount = giveaways.filter(g => g.status === 'COMPLETED').length;
const totalEligible = giveaways.reduce((acc, g) => acc + (g.drawResult?.totalEligibleCount || 0), 0);
return (
<div className="space-y-8">
{/* Hero Welcome Banner */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-blue-900/40 via-indigo-900/30 to-slate-900 border border-blue-500/20 p-8 shadow-xl">
<div className="relative z-10 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-400/30 text-blue-300 text-xs font-semibold mb-4">
<Sparkles className="w-3.5 h-3.5" />
Честные розыгрыши ВКонтакте
</div>
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-4xl mb-3">
Честный рандомайзер с доказуемым результатом
</h1>
<p className="text-slate-300 text-sm sm:text-base mb-6 leading-relaxed">
Выбирайте победителей по лайкам, комментариям и репостам.
Каждый розыгрыш фиксируется криптографическим хешем и seed для 100% прозрачности.
</p>
<div className="flex flex-wrap items-center gap-4">
<Link
href="/giveaways/new"
className="inline-flex items-center gap-2 px-5 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-medium shadow-lg shadow-blue-600/30 transition-all active:scale-95"
>
<PlusCircle className="w-5 h-5" />
Создать новый розыгрыш
</Link>
</div>
</div>
<div className="absolute right-6 top-1/2 -translate-y-1/2 hidden lg:block opacity-15 pointer-events-none">
<Gift className="w-64 h-64 text-blue-400" />
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Всего кампаний</span>
<Gift className="w-4 h-4 text-blue-400" />
</div>
<div className="text-2xl font-bold text-white">{giveaways.length}</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Проведено розыгрышей</span>
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
</div>
<div className="text-2xl font-bold text-emerald-400">{completedCount}</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between text-slate-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Участников в розыгрышах</span>
<Users className="w-4 h-4 text-indigo-400" />
</div>
<div className="text-2xl font-bold text-indigo-400">{totalEligible}</div>
</div>
</div>
{/* List of Giveaways */}
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-white">Список розыгрышей</h2>
<p className="text-xs text-slate-400">История созданных и завершенных конкурсов</p>
</div>
<button
onClick={fetchGiveaways}
disabled={loading}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
title="Обновить"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{loading ? (
<div className="py-12 text-center text-slate-400 text-sm">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-500" />
Загрузка списка...
</div>
) : giveaways.length === 0 ? (
<div className="py-12 text-center border border-dashed border-slate-800 rounded-xl bg-slate-950/40">
<Gift className="w-10 h-10 text-slate-400 mx-auto mb-3" />
<p className="text-sm text-slate-300 font-medium mb-1">Пока нет созданных розыгрышей</p>
<p className="text-xs text-slate-400 mb-4 max-w-sm mx-auto">
Вставьте ссылку на пост ВКонтакте, чтобы загрузить участников и определить победителя
</p>
<Link
href="/giveaways/new"
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
<PlusCircle className="w-3.5 h-3.5" />
Создать первый розыгрыш
</Link>
</div>
) : (
<div className="space-y-3">
{giveaways.map((gw) => (
<div
key={gw.id}
className="group flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-xl bg-slate-950/60 border border-slate-800/80 hover:border-blue-500/50 transition-all gap-4"
>
<div className="flex items-start gap-3.5 min-w-0">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0 text-blue-400 font-bold text-xs mt-0.5">
VK
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
{gw.title || 'Розыгрыш по записи VK'}
</h3>
<p className="text-xs text-slate-400 truncate mt-0.5 max-w-md">
{gw.description || gw.sourceUrl}
</p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-400">
<span>Создан: {new Date(gw.createdAt).toLocaleDateString('ru-RU')}</span>
<span></span>
<span>Лайков: {gw.postLikesCount}</span>
<span></span>
<span>Комментов: {gw.postCommentsCount}</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 self-end sm:self-center">
{gw.status === 'COMPLETED' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle2 className="w-3 h-3" />
Завершен
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Clock className="w-3 h-3" />
Готов к проведению
</span>
)}
<Link
href={`/giveaways/${gw.id}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 hover:bg-slate-700 text-white transition-colors"
>
Подробнее
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Provably Fair Info Box */}
<div className="rounded-xl bg-slate-900/40 border border-slate-800 p-6 flex flex-col md:flex-row items-start md:items-center gap-4">
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 shrink-0">
<ShieldCheck className="w-8 h-8" />
</div>
<div>
<h3 className="text-sm font-semibold text-white mb-1">
Как гарантируется честность результатов?
</h3>
<p className="text-xs text-slate-400 leading-relaxed">
Перед жеребьевкой список участников сортируется и хешируется по стандарту SHA-256.
Победитель определяется алгоритмом HMAC-SHA256 на основе фиксированного seed. Любой зритель может воспроизвести результат и проверить неизменность выборки.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,119 @@
import { FilterRules } from '../types/giveaway';
import { RawParticipant, FilteredParticipant } from '../types/participant';
export interface FilterResult {
allParticipants: FilteredParticipant[];
eligibleParticipants: FilteredParticipant[];
excludedParticipants: FilteredParticipant[];
stats: {
total: number;
eligibleCount: number;
excludedCount: number;
reasonsBreakdown: Record<string, number>;
};
}
/**
* Filter Engine evaluates a list of raw participants against the configured giveaway filter rules.
* Handles deduplication, action requirements (like, comment, repost, sub), admin exclusion, and blacklists.
*/
export function applyFilterRules(
participants: RawParticipant[],
rules: FilterRules
): FilterResult {
// 1. Deduplicate participants by platformUserId (if enabled, aggregate comment counts)
const participantMap = new Map<string, RawParticipant>();
for (const p of participants) {
const existing = participantMap.get(p.platformUserId);
if (!existing) {
participantMap.set(p.platformUserId, { ...p });
} else {
// Merge actions
existing.liked = existing.liked || p.liked;
existing.commented = existing.commented || p.commented;
existing.commentsCount = (existing.commentsCount || 0) + (p.commentsCount || 1);
existing.reposted = existing.reposted || p.reposted;
existing.subscribed = existing.subscribed || p.subscribed;
existing.isAdmin = existing.isAdmin || p.isAdmin;
}
}
const deduplicated = Array.from(participantMap.values());
const blacklistedSet = new Set(
(rules.excludeBlacklistedIds || []).map(id => id.trim().toLowerCase().replace(/^@/, ''))
);
const allFiltered: FilteredParticipant[] = [];
const eligibleList: FilteredParticipant[] = [];
const excludedList: FilteredParticipant[] = [];
const reasonsBreakdown: Record<string, number> = {};
for (const p of deduplicated) {
const reasons: string[] = [];
// Check Blacklist
const cleanId = p.platformUserId.toLowerCase();
const cleanUsername = (p.username || '').toLowerCase();
if (blacklistedSet.has(cleanId) || (cleanUsername && blacklistedSet.has(cleanUsername))) {
reasons.push('BLACKLISTED');
}
// Check Admin Exclusion
if (rules.excludeAdmins && p.isAdmin) {
reasons.push('IS_ADMIN');
}
// Check Likes Requirement
if (rules.requireLike && !p.liked) {
reasons.push('MISSING_LIKE');
}
// Check Comments Requirement
if (rules.requireComment && (!p.commented || p.commentsCount < 1)) {
reasons.push('MISSING_COMMENT');
}
// Check Repost Requirement
if (rules.requireRepost && !p.reposted) {
reasons.push('MISSING_REPOST');
}
// Check Subscription Requirement
if (rules.requireSubscription && !p.subscribed) {
reasons.push('NOT_SUBSCRIBED');
}
const isEligible = reasons.length === 0;
const exclusionReason = isEligible ? null : reasons.join(', ');
const filteredItem: FilteredParticipant = {
...p,
eligible: isEligible,
exclusionReason,
};
allFiltered.push(filteredItem);
if (isEligible) {
eligibleList.push(filteredItem);
} else {
excludedList.push(filteredItem);
for (const r of reasons) {
reasonsBreakdown[r] = (reasonsBreakdown[r] || 0) + 1;
}
}
}
return {
allParticipants: allFiltered,
eligibleParticipants: eligibleList,
excludedParticipants: excludedList,
stats: {
total: allFiltered.length,
eligibleCount: eligibleList.length,
excludedCount: excludedList.length,
reasonsBreakdown,
},
};
}

View file

@ -0,0 +1,158 @@
import { createHmac, createHash } from 'crypto';
import { FilteredParticipant, Winner } from '../types/participant';
import { DrawExecutionParams, DrawExecutionResult } from '../types/audit';
import { computeParticipantsSnapshotHash } from './hasher';
const ALGORITHM_NAME = 'HMAC-SHA256-SEEDED-SELECTION-V1';
/**
* Deterministic pseudo-random number generator using HMAC-SHA256.
* Given a seed, snapshot hash, and step/index, generates a deterministic 32-bit unsigned integer.
*/
export function getDeterministicUint32(seed: string, snapshotHash: string, step: number): number {
const hmac = createHmac('sha256', seed);
hmac.update(`${snapshotHash}:step:${step}`);
const hashBuffer = hmac.digest();
// Read first 4 bytes as unsigned 32-bit big endian integer
return hashBuffer.readUInt32BE(0);
}
/**
* Generates an audit proof hash for a specific winner step
*/
export function generateWinnerProofHash(seed: string, snapshotHash: string, position: number, participantId: string): string {
return createHash('sha256')
.update(`${seed}:${snapshotHash}:pos:${position}:id:${participantId}`)
.digest('hex');
}
/**
* Executes a deterministic draw on a set of eligible participants.
* Guarantees that the same eligible participants + same seed ALWAYS produce the exact same winners.
*/
export function executeDeterministicDraw(params: DrawExecutionParams): DrawExecutionResult {
const {
giveawayId,
eligibleParticipants,
totalLoadedCount,
winnersCount,
reserveWinnersCount,
seed,
filterRules,
} = params;
if (eligibleParticipants.length === 0) {
throw new Error('Cannot conduct draw with 0 eligible participants');
}
// 1. Canonical sort to guarantee stability
const sortedParticipants = [...eligibleParticipants].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId)
);
// 2. Compute canonical snapshot hash
const snapshotHash = computeParticipantsSnapshotHash(sortedParticipants);
// 3. Clone pool for sampling without replacement
const pool = [...sortedParticipants];
const winners: Winner[] = [];
const reserveWinners: Winner[] = [];
const totalNeeded = Math.min(winnersCount + reserveWinnersCount, pool.length);
const actualWinnersCount = Math.min(winnersCount, totalNeeded);
const actualReserveCount = Math.max(0, totalNeeded - actualWinnersCount);
let step = 0;
// 4. Select Main Winners
for (let i = 0; i < actualWinnersCount; i++) {
const randUint = getDeterministicUint32(seed, snapshotHash, step);
const selectedIndex = randUint % pool.length;
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
const proofHash = generateWinnerProofHash(seed, snapshotHash, i + 1, selectedParticipant.platformUserId);
winners.push({
position: i + 1,
isReserve: false,
participant: selectedParticipant,
selectionIndex: selectedIndex,
proofHash,
});
step++;
}
// 5. Select Reserve Winners
for (let i = 0; i < actualReserveCount; i++) {
const randUint = getDeterministicUint32(seed, snapshotHash, step);
const selectedIndex = randUint % pool.length;
const selectedParticipant = pool.splice(selectedIndex, 1)[0];
const proofHash = generateWinnerProofHash(seed, snapshotHash, winners.length + i + 1, selectedParticipant.platformUserId);
reserveWinners.push({
position: winners.length + i + 1,
isReserve: true,
participant: selectedParticipant,
selectionIndex: selectedIndex,
proofHash,
});
step++;
}
const drawnAt = new Date().toISOString();
// 6. Compute overall verification signature
const verificationSignature = createHash('sha256')
.update(JSON.stringify({
giveawayId,
snapshotHash,
seed,
algorithm: ALGORITHM_NAME,
winnerIds: winners.map(w => w.participant.platformUserId),
reserveIds: reserveWinners.map(w => w.participant.platformUserId),
}))
.digest('hex');
return {
giveawayId,
winners,
reserveWinners,
totalEligibleCount: eligibleParticipants.length,
totalLoadedCount,
seedUsed: seed,
participantsSnapshotHash: snapshotHash,
algorithm: ALGORITHM_NAME,
drawnAt,
verificationSignature,
};
}
/**
* Re-runs the draw algorithm with the given snapshot of participants and seed
* to verify if the produced outcome matches the claimed outcome.
*/
export function verifyDrawResult(
eligibleParticipants: FilteredParticipant[],
seed: string,
claimedWinnersCount: number,
claimedReserveCount: number
): { winners: Winner[]; reserveWinners: Winner[]; snapshotHash: string } {
const result = executeDeterministicDraw({
giveawayId: 'verification',
eligibleParticipants,
totalLoadedCount: eligibleParticipants.length,
winnersCount: claimedWinnersCount,
reserveWinnersCount: claimedReserveCount,
seed,
filterRules: {} as any,
});
return {
winners: result.winners,
reserveWinners: result.reserveWinners,
snapshotHash: result.participantsSnapshotHash,
};
}

View file

@ -0,0 +1,39 @@
import { createHash } from 'crypto';
import { FilteredParticipant } from '../types/participant';
/**
* Computes a deterministic SHA-256 snapshot hash for a list of participants.
* Participants are sorted canonically by platformUserId to guarantee identical hash
* regardless of initial retrieval order.
*/
export function computeParticipantsSnapshotHash(participants: FilteredParticipant[]): string {
// Canonical sort by platformUserId
const sorted = [...participants].sort((a, b) =>
a.platformUserId.localeCompare(b.platformUserId)
);
const canonicalRepresentation = sorted.map(p => ({
id: p.platformUserId,
name: `${p.firstName} ${p.lastName}`.trim(),
username: p.username || '',
actions: {
liked: p.liked,
commented: p.commented,
reposted: p.reposted,
subscribed: p.subscribed,
}
}));
const jsonString = JSON.stringify(canonicalRepresentation);
return createHash('sha256').update(jsonString, 'utf8').digest('hex');
}
/**
* Generates a random crypto seed if not provided by user
*/
export function generateRandomSeed(): string {
return createHash('sha256')
.update(`${Date.now()}-${Math.random()}-${process.pid}`)
.digest('hex')
.slice(0, 16);
}

35
src/core/types/audit.ts Normal file
View file

@ -0,0 +1,35 @@
import { FilterRules } from './giveaway';
import { FilteredParticipant, Winner } from './participant';
export interface DrawExecutionParams {
giveawayId: string;
eligibleParticipants: FilteredParticipant[];
totalLoadedCount: number;
winnersCount: number;
reserveWinnersCount: number;
seed: string;
filterRules: FilterRules;
}
export interface DrawExecutionResult {
giveawayId: string;
winners: Winner[];
reserveWinners: Winner[];
totalEligibleCount: number;
totalLoadedCount: number;
seedUsed: string;
participantsSnapshotHash: string;
algorithm: string;
drawnAt: string; // ISO String
verificationSignature: string;
}
export interface AuditVerificationData {
participantsSnapshotHash: string;
seed: string;
algorithm: string;
filterRulesSnapshot: FilterRules;
winners: Winner[];
reserveWinners: Winner[];
drawnAt: string;
}

View file

@ -0,0 +1,44 @@
export type PlatformType = 'VK' | 'TELEGRAM' | 'YOUTUBE';
export type GiveawayStatusType = 'DRAFT' | 'FETCHING' | 'READY' | 'COMPLETED' | 'CANCELLED';
export type ParticipantSourceType = 'LIKES' | 'COMMENTS' | 'REPOSTS' | 'COMBINED';
export interface FilterRules {
requireLike: boolean;
requireComment: boolean;
requireRepost: boolean;
requireSubscription: boolean;
targetGroupId?: string;
excludeAdmins: boolean;
excludeBlacklistedIds: string[]; // List of user IDs or usernames to exclude
excludeDuplicateComments: boolean; // Count user only once even if multiple comments
minEligibleParticipants?: number;
}
export const DEFAULT_FILTER_RULES: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: true,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
minEligibleParticipants: 1,
};
export interface PostMetadata {
platform: PlatformType;
ownerId: string;
postId: string;
sourceUrl: string;
title: string;
text: string;
authorName?: string;
authorAvatarUrl?: string;
imageUrl?: string;
likesCount: number;
commentsCount: number;
repostsCount: number;
publishedAt?: Date;
}

View file

@ -0,0 +1,36 @@
import { ParticipantSourceType } from './giveaway';
export interface ParticipantProfile {
platformUserId: string;
firstName: string;
lastName: string;
username?: string;
avatarUrl?: string;
}
export interface ParticipantActions {
liked: boolean;
commented: boolean;
commentsCount: number;
reposted: boolean;
subscribed: boolean;
isAdmin?: boolean;
}
export interface RawParticipant extends ParticipantProfile, ParticipantActions {
source: ParticipantSourceType;
}
export interface FilteredParticipant extends RawParticipant {
id?: string;
eligible: boolean;
exclusionReason?: string | null;
}
export interface Winner {
position: number; // 1, 2, 3...
isReserve: boolean;
participant: FilteredParticipant;
selectionIndex: number;
proofHash: string;
}

102
src/lib/giveaway-store.ts Normal file
View file

@ -0,0 +1,102 @@
import { FilterRules, GiveawayStatusType, PlatformType, PostMetadata } from '../core/types/giveaway';
import { FilteredParticipant, RawParticipant, Winner } from '../core/types/participant';
import { DrawExecutionResult } from '../core/types/audit';
export interface StoredGiveaway {
id: string;
platform: PlatformType;
sourceUrl: string;
platformOwnerId: string;
platformPostId: string;
title: string;
description?: string;
postImageUrl?: string;
postLikesCount: number;
postCommentsCount: number;
postRepostsCount: number;
status: GiveawayStatusType;
filterRules: FilterRules;
winnersCount: number;
reserveWinnersCount: number;
seed?: string;
createdAt: string;
updatedAt: string;
drawnAt?: string;
participants: FilteredParticipant[];
drawResult?: DrawExecutionResult;
}
// In-memory runtime cache/store for fast UI state & standalone dev mode
const memoryStore = new Map<string, StoredGiveaway>();
export class GiveawayStore {
static async create(data: {
sourceUrl: string;
post: PostMetadata;
filterRules: FilterRules;
winnersCount?: number;
reserveWinnersCount?: number;
seed?: string;
}): Promise<StoredGiveaway> {
const id = 'gw_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const now = new Date().toISOString();
const giveaway: StoredGiveaway = {
id,
platform: data.post.platform,
sourceUrl: data.sourceUrl,
platformOwnerId: data.post.ownerId,
platformPostId: data.post.postId,
title: data.post.title,
description: data.post.text,
postImageUrl: data.post.imageUrl,
postLikesCount: data.post.likesCount,
postCommentsCount: data.post.commentsCount,
postRepostsCount: data.post.repostsCount,
status: 'READY',
filterRules: data.filterRules,
winnersCount: data.winnersCount || 1,
reserveWinnersCount: data.reserveWinnersCount || 0,
seed: data.seed,
createdAt: now,
updatedAt: now,
participants: [],
};
memoryStore.set(id, giveaway);
return giveaway;
}
static async getById(id: string): Promise<StoredGiveaway | null> {
return memoryStore.get(id) || null;
}
static async listAll(): Promise<StoredGiveaway[]> {
return Array.from(memoryStore.values()).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
static async updateParticipants(id: string, participants: FilteredParticipant[]): Promise<StoredGiveaway> {
const gw = memoryStore.get(id);
if (!gw) throw new Error('Giveaway not found');
gw.participants = participants;
gw.updatedAt = new Date().toISOString();
memoryStore.set(id, gw);
return gw;
}
static async saveDrawResult(id: string, result: DrawExecutionResult): Promise<StoredGiveaway> {
const gw = memoryStore.get(id);
if (!gw) throw new Error('Giveaway not found');
gw.drawResult = result;
gw.status = 'COMPLETED';
gw.drawnAt = result.drawnAt;
gw.seed = result.seedUsed;
gw.updatedAt = new Date().toISOString();
memoryStore.set(id, gw);
return gw;
}
}

13
src/lib/prisma.ts Normal file
View file

@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

41
src/providers/registry.ts Normal file
View file

@ -0,0 +1,41 @@
import { PlatformType } from '../core/types/giveaway';
import { SocialMediaProvider } from './types';
import { VkMockProvider } from './vk/vk-mock-provider';
import { VkProvider } from './vk/vk-provider';
export class ProviderRegistry {
private static providers: Map<PlatformType, SocialMediaProvider> = new Map();
static {
// Determine whether to use real VK provider or mock provider
const useRealVk = Boolean(process.env.VK_SERVICE_TOKEN && process.env.VK_SERVICE_TOKEN.trim().length > 10);
const vkProvider = useRealVk ? new VkProvider() : new VkMockProvider();
this.providers.set('VK', vkProvider);
}
/**
* Register or override a provider for a platform
*/
static registerProvider(platform: PlatformType, provider: SocialMediaProvider): void {
this.providers.set(platform, provider);
}
/**
* Get provider by platform type
*/
static getProvider(platform: PlatformType): SocialMediaProvider {
const provider = this.providers.get(platform);
if (!provider) {
throw new Error(`Provider for platform "${platform}" is not registered`);
}
return provider;
}
/**
* Force set mock provider for testing purposes
*/
static useMockVk(): void {
this.providers.set('VK', new VkMockProvider());
}
}

38
src/providers/types.ts Normal file
View file

@ -0,0 +1,38 @@
import { PlatformType, PostMetadata } from '../core/types/giveaway';
import { RawParticipant } from '../core/types/participant';
export interface FetchParticipantsParams {
ownerId: string;
postId: string;
sourceUrl?: string;
targetGroupId?: string;
includeLikes?: boolean;
includeComments?: boolean;
includeReposts?: boolean;
checkSubscription?: boolean;
onProgress?: (loaded: number, total: number, message: string) => void;
}
export interface SocialMediaProvider {
readonly platform: PlatformType;
/**
* Parse a raw URL from the user into ownerId and postId
*/
parsePostUrl(url: string): { ownerId: string; postId: string } | null;
/**
* Fetch post metadata, text, counters, and image
*/
fetchPost(url: string): Promise<PostMetadata>;
/**
* Fetch all raw participants performing actions on the post
*/
fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]>;
/**
* Batch check membership in a community/channel
*/
checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>>;
}

View file

@ -0,0 +1,117 @@
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
export class VkMockProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url);
}
async fetchPost(url: string): Promise<PostMetadata> {
const parsed = this.parsePostUrl(url);
const ownerId = parsed ? parsed.ownerId : '-22446688';
const postId = parsed ? parsed.postId : '1054';
// Simulate short network latency
await new Promise(r => setTimeout(r, 400));
return {
platform: 'VK',
ownerId,
postId,
sourceUrl: url.startsWith('http') ? url : `https://vk.com/wall${ownerId}_${postId}`,
title: 'Большой весенний розыгрыш подарков!',
text: '🎉 Внимание! Разыгрываем крутые призы среди наших подписчиков!\n\nУсловия просты:\n1. Поставить лайк этому посту ❤️\n2. Написать любой комментарий 💬\n3. Быть подписанным на наше сообщество ✨\n\nИтоги подведем честно и прозрачно через генератор случайных чисел!',
authorName: 'Официальное сообщество Randomayzer',
authorAvatarUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=150&auto=format&fit=crop&q=80',
imageUrl: 'https://images.unsplash.com/photo-1513151233558-d860c5398176?w=800&auto=format&fit=crop&q=80',
likesCount: 142,
commentsCount: 86,
repostsCount: 37,
publishedAt: new Date(Date.now() - 3600000 * 24 * 2),
};
}
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
// Simulate pagination / progress
if (params.onProgress) {
params.onProgress(50, 150, 'Загрузка лайков...');
await new Promise(r => setTimeout(r, 200));
params.onProgress(100, 150, 'Загрузка комментариев...');
await new Promise(r => setTimeout(r, 200));
params.onProgress(150, 150, 'Проверка подписок...');
}
const mockNames = [
{ first: 'Алексей', last: 'Смирнов', user: 'smirnov_alex' },
{ first: 'Екатерина', last: 'Иванова', user: 'katya_iva' },
{ first: 'Дмитрий', last: 'Кузнецов', user: 'kuznetsov_d' },
{ first: 'Анна', last: 'Попова', user: 'anna_popova' },
{ first: 'Михаил', last: 'Соколов', user: 'misha_sokol' },
{ first: 'Елена', last: 'Лебедева', user: 'elena_leb' },
{ first: 'Сергей', last: 'Козлов', user: 'sergey_kozlov' },
{ first: 'Ольга', last: 'Новикова', user: 'olga_nov' },
{ first: 'Иван', last: 'Морозов', user: 'ivan_moroz' },
{ first: 'Татьяна', last: 'Петрова', user: 'tatyana_p' },
{ first: 'Артем', last: 'Волков', user: 'artem_volkov' },
{ first: 'Мария', last: 'Соловьева', user: 'maria_sol' },
{ first: 'Максим', last: 'Васильев', user: 'max_vas' },
{ first: 'Виктория', last: 'Зайцева', user: 'vika_zaytseva' },
{ first: 'Павел', last: 'Павлов', user: 'pavel_p' },
{ first: 'Ксения', last: 'Семенова', user: 'ksenia_sem' },
{ first: 'Роман', last: 'Голубев', user: 'roman_g' },
{ first: 'Алина', last: 'Виноградова', user: 'alina_vin' },
{ first: 'Денис', last: 'Богданов', user: 'denis_bogdan' },
{ first: 'Анастасия', last: 'Воробьева', user: 'nastya_vorob' },
{ first: 'Илья', last: 'Федоров', user: 'ilya_fed' },
{ first: 'Полина', last: 'Михайлова', user: 'polina_m' },
{ first: 'Владимир', last: 'Беляев', user: 'vlad_bel' },
{ first: 'Дарья', last: 'Тарасова', user: 'daria_t' },
{ first: 'Никита', last: 'Белов', user: 'nikita_bel' },
];
const participants: RawParticipant[] = [];
// Generate 35 mock participants with varied attributes
for (let i = 1; i <= 35; i++) {
const nameObj = mockNames[(i - 1) % mockNames.length];
const userId = `${1000000 + i * 137}`;
// Determine varied conditions for realistic testing
const liked = i !== 7 && i !== 19; // 7 and 19 didn't like
const commented = i % 2 === 0 || i % 3 === 0; // some commented
const commentsCount = commented ? (i % 5 === 0 ? 3 : 1) : 0; // some wrote duplicate comments
const reposted = i % 3 === 0; // some reposted
const subscribed = i !== 13 && i !== 27; // 13 and 27 not subscribed
const isAdmin = i === 1; // Participant 1 is admin
participants.push({
platformUserId: userId,
firstName: nameObj.first + (i > mockNames.length ? ` ${Math.floor(i / mockNames.length) + 1}` : ''),
lastName: nameObj.last,
username: `${nameObj.user}_${i}`,
avatarUrl: `https://images.unsplash.com/photo-${1534528741775 + (i * 1000)}?w=100&auto=format&fit=crop&q=80`,
source: 'COMBINED',
liked,
commented,
commentsCount,
reposted,
subscribed,
isAdmin,
});
}
return participants;
}
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
for (const id of userIds) {
result.set(id, id !== '1001781'); // mock
}
return result;
}
}

View file

@ -0,0 +1,44 @@
/**
* Parses various formats of VKontakte post URLs:
* - https://vk.com/wall-123456_789
* - https://vk.ru/wall123456_789
* - https://m.vk.com/wall-123456_789
* - https://vk.com/club123456?w=wall-123456_789
* - https://vk.com/public123456?w=wall-123456_789
* - wall-123456_789
* - -123456_789
*/
export function parseVkPostUrl(input: string): { ownerId: string; postId: string } | null {
if (!input || typeof input !== 'string') return null;
const trimmed = input.trim();
// Pattern 1: Direct "wall-123_456" or "-123_456"
const directMatch = trimmed.match(/^(?:wall)?(-?\d+)_(\d+)$/i);
if (directMatch) {
return {
ownerId: directMatch[1],
postId: directMatch[2],
};
}
// Pattern 2: URL with ?w=wall-123_456 or &w=wall-123_456
const queryWallMatch = trimmed.match(/[?&]w=wall(-?\d+)_(\d+)/i);
if (queryWallMatch) {
return {
ownerId: queryWallMatch[1],
postId: queryWallMatch[2],
};
}
// Pattern 3: Standard URL path https://vk.com/wall-123_456 or https://m.vk.com/wall-123_456
const urlWallMatch = trimmed.match(/(?:vk\.com|vk\.ru|m\.vk\.com)\/wall(-?\d+)_(\d+)/i);
if (urlWallMatch) {
return {
ownerId: urlWallMatch[1],
postId: urlWallMatch[2],
};
}
return null;
}

View file

@ -0,0 +1,258 @@
import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
interface VkApiResponse<T> {
response?: T;
error?: {
error_code: number;
error_msg: string;
};
}
export class VkProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
private serviceToken?: string;
private apiVersion = '5.199';
private baseUrl = 'https://api.vk.com/method';
constructor(serviceToken?: string) {
this.serviceToken = serviceToken || process.env.VK_SERVICE_TOKEN;
}
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url);
}
private async callApi<T>(method: string, params: Record<string, string | number>): Promise<T> {
if (!this.serviceToken) {
throw new Error('VK_SERVICE_TOKEN is not configured in environment variables');
}
const query = new URLSearchParams({
...Object.entries(params).reduce((acc, [k, v]) => ({ ...acc, [k]: String(v) }), {}),
access_token: this.serviceToken,
v: this.apiVersion,
});
const response = await fetch(`${this.baseUrl}/${method}?${query.toString()}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`VK API HTTP error: ${response.status} ${response.statusText}`);
}
const data: VkApiResponse<T> = await response.json();
if (data.error) {
throw new Error(`VK API Error (${data.error.error_code}): ${data.error.error_msg}`);
}
if (!data.response) {
throw new Error('Empty response from VK API');
}
return data.response;
}
async fetchPost(url: string): Promise<PostMetadata> {
const parsed = this.parsePostUrl(url);
if (!parsed) {
throw new Error('Invalid VK post URL format');
}
const { ownerId, postId } = parsed;
const response = await this.callApi<{ items: any[]; profiles?: any[]; groups?: any[] }>(
'wall.getById',
{
posts: `${ownerId}_${postId}`,
extended: 1,
}
);
if (!response.items || response.items.length === 0) {
throw new Error('Post not found or access is restricted');
}
const post = response.items[0];
// Find author name / avatar
let authorName = `VK Wall ${ownerId}`;
let authorAvatarUrl = undefined;
if (ownerId.startsWith('-')) {
const groupId = Math.abs(parseInt(ownerId, 10));
const group = (response.groups || []).find(g => g.id === groupId);
if (group) {
authorName = group.name;
authorAvatarUrl = group.photo_100 || group.photo_200;
}
} else {
const userId = parseInt(ownerId, 10);
const profile = (response.profiles || []).find(p => p.id === userId);
if (profile) {
authorName = `${profile.first_name} ${profile.last_name}`;
authorAvatarUrl = profile.photo_100 || profile.photo_200;
}
}
// Extract first image attachment if available
let imageUrl = undefined;
if (post.attachments && post.attachments.length > 0) {
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
if (photoAttachment && photoAttachment.photo && photoAttachment.photo.sizes) {
const sizes = photoAttachment.photo.sizes;
imageUrl = sizes[sizes.length - 1]?.url;
}
}
return {
platform: 'VK',
ownerId,
postId,
sourceUrl: url,
title: post.text ? post.text.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
text: post.text || '',
authorName,
authorAvatarUrl,
imageUrl,
likesCount: post.likes?.count || 0,
commentsCount: post.comments?.count || 0,
repostsCount: post.reposts?.count || 0,
publishedAt: post.date ? new Date(post.date * 1000) : undefined,
};
}
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
const { ownerId, postId } = params;
const participantsMap = new Map<string, RawParticipant>();
// 1. Fetch Likes
if (params.includeLikes !== false) {
let offset = 0;
const count = 1000;
let totalLikes = 0;
do {
const likesRes = await this.callApi<{ count: number; items: any[] }>('likes.getList', {
type: 'post',
owner_id: ownerId,
item_id: postId,
filter: 'likes',
extended: 1,
count,
offset,
});
totalLikes = likesRes.count;
for (const item of likesRes.items) {
const userId = String(item.id);
participantsMap.set(userId, {
platformUserId: userId,
firstName: item.first_name || '',
lastName: item.last_name || '',
username: item.screen_name || undefined,
avatarUrl: item.photo_100 || item.photo_200,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
});
}
offset += count;
if (params.onProgress) {
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
}
} while (offset < totalLikes && offset < 5000); // capped for phase 1 protection
}
// 2. Fetch Comments
if (params.includeComments) {
let offset = 0;
const count = 100;
let totalComments = 0;
do {
const commentsRes = await this.callApi<{ count: number; items: any[]; profiles?: any[] }>(
'wall.getComments',
{
owner_id: ownerId,
post_id: postId,
extended: 1,
count,
offset,
fields: 'photo_100,photo_200,screen_name',
}
);
totalComments = commentsRes.count;
const profileMap = new Map<number, any>(
(commentsRes.profiles || []).map(p => [p.id, p])
);
for (const item of commentsRes.items) {
if (item.from_id && item.from_id > 0) {
const userId = String(item.from_id);
const prof = profileMap.get(item.from_id);
const existing = participantsMap.get(userId);
if (existing) {
existing.commented = true;
existing.commentsCount = (existing.commentsCount || 0) + 1;
} else {
participantsMap.set(userId, {
platformUserId: userId,
firstName: prof?.first_name || 'Участник',
lastName: prof?.last_name || userId,
username: prof?.screen_name,
avatarUrl: prof?.photo_100,
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: false,
});
}
}
}
offset += count;
} while (offset < totalComments && offset < 1000);
}
return Array.from(participantsMap.values());
}
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
const cleanGroupId = groupId.replace(/^-/, '');
const resultMap = new Map<string, boolean>();
// Batch in chunks of 500 as supported by groups.isMember
const chunkSize = 500;
for (let i = 0; i < userIds.length; i += chunkSize) {
const chunk = userIds.slice(i, i + chunkSize);
const res = await this.callApi<Array<{ user_id: number; member: number }>>(
'groups.isMember',
{
group_id: cleanGroupId,
user_ids: chunk.join(','),
}
);
for (const item of res) {
resultMap.set(String(item.user_id), item.member === 1);
}
}
return resultMap;
}
}

29
tailwind.config.ts Normal file
View file

@ -0,0 +1,29 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
primary: {
DEFAULT: "#2563eb",
hover: "#1d4ed8",
dark: "#1e40af",
},
vk: {
DEFAULT: "#0077FF",
hover: "#0066DD",
dark: "#0055BB",
},
},
},
},
plugins: [],
};
export default config;

197
tests/filter-engine.test.ts Normal file
View file

@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest';
import { applyFilterRules } from '../src/core/filtering/filter-engine';
import { RawParticipant } from '../src/core/types/participant';
import { FilterRules } from '../src/core/types/giveaway';
describe('Filter Engine', () => {
const sampleParticipants: RawParticipant[] = [
{
platformUserId: '1',
firstName: 'Иван',
lastName: 'Иванов',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: true,
subscribed: true,
isAdmin: false,
},
{
platformUserId: '2',
firstName: 'Петр',
lastName: 'Петров',
source: 'LIKES',
liked: false,
commented: true,
commentsCount: 1,
reposted: true,
subscribed: true,
isAdmin: false,
},
{
platformUserId: '3',
firstName: 'Анна',
lastName: 'Сидорова',
source: 'COMMENTS',
liked: true,
commented: false,
commentsCount: 0,
reposted: true,
subscribed: true,
isAdmin: false,
},
{
platformUserId: '4',
firstName: 'Админ',
lastName: 'Группы',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: true,
subscribed: true,
isAdmin: true,
},
{
platformUserId: '5',
firstName: 'Не',
lastName: 'Подписан',
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: true,
subscribed: false,
isAdmin: false,
},
];
it('should filter by like requirement correctly', () => {
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(sampleParticipants, rules);
expect(result.stats.eligibleCount).toBe(4);
expect(result.stats.excludedCount).toBe(1);
expect(result.excludedParticipants[0].platformUserId).toBe('2');
expect(result.excludedParticipants[0].exclusionReason).toBe('MISSING_LIKE');
});
it('should filter by comment and subscription requirements', () => {
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(sampleParticipants, rules);
// 1: passed
// 2: missing like
// 3: missing comment
// 4: passed (admin allowed here)
// 5: not subscribed
expect(result.stats.eligibleCount).toBe(2);
const eligibleIds = result.eligibleParticipants.map(p => p.platformUserId);
expect(eligibleIds).toEqual(['1', '4']);
});
it('should exclude administrators when excludeAdmins is true', () => {
const rules: FilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: true,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(sampleParticipants, rules);
const adminParticipant = result.allParticipants.find(p => p.platformUserId === '4');
expect(adminParticipant?.eligible).toBe(false);
expect(adminParticipant?.exclusionReason).toContain('IS_ADMIN');
});
it('should exclude blacklisted user IDs and usernames', () => {
const rules: FilterRules = {
requireLike: false,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: ['1', '5'],
excludeDuplicateComments: true,
};
const result = applyFilterRules(sampleParticipants, rules);
const blacklisted = result.excludedParticipants.map(p => p.platformUserId);
expect(blacklisted).toContain('1');
expect(blacklisted).toContain('5');
expect(result.stats.reasonsBreakdown['BLACKLISTED']).toBe(2);
});
it('should deduplicate multiple raw comments from the same user into single participant', () => {
const multiComments: RawParticipant[] = [
{
platformUserId: '100',
firstName: 'Спамер',
lastName: 'Обыкновенный',
source: 'COMMENTS',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
{
platformUserId: '100',
firstName: 'Спамер',
lastName: 'Обыкновенный',
source: 'COMMENTS',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
{
platformUserId: '100',
firstName: 'Спамер',
lastName: 'Обыкновенный',
source: 'COMMENTS',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
];
const rules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: true,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
};
const result = applyFilterRules(multiComments, rules);
expect(result.stats.total).toBe(1);
expect(result.stats.eligibleCount).toBe(1);
expect(result.allParticipants[0].commentsCount).toBe(3);
});
});

202
tests/randomizer.test.ts Normal file
View file

@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest';
import { executeDeterministicDraw, verifyDrawResult } from '../src/core/randomizer/deterministic';
import { computeParticipantsSnapshotHash, generateRandomSeed } from '../src/core/randomizer/hasher';
import { FilteredParticipant } from '../src/core/types/participant';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
function createMockEligibleParticipants(count: number): FilteredParticipant[] {
return Array.from({ length: count }, (_, i) => ({
platformUserId: `${1000 + i}`,
firstName: `User${i + 1}`,
lastName: `Surname${i + 1}`,
username: `user_${i + 1}`,
avatarUrl: `https://example.com/avatar/${i + 1}.jpg`,
source: 'LIKES',
liked: true,
commented: true,
commentsCount: 1,
reposted: true,
subscribed: true,
eligible: true,
exclusionReason: null,
}));
}
describe('Deterministic Randomizer & Provably Fair Engine', () => {
it('should generate identical winners given the same participants snapshot and seed', () => {
const participants = createMockEligibleParticipants(50);
const seed = 'test-secret-seed-2026';
const draw1 = executeDeterministicDraw({
giveawayId: 'gw-1',
eligibleParticipants: participants,
totalLoadedCount: 50,
winnersCount: 3,
reserveWinnersCount: 2,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
const draw2 = executeDeterministicDraw({
giveawayId: 'gw-1',
eligibleParticipants: participants,
totalLoadedCount: 50,
winnersCount: 3,
reserveWinnersCount: 2,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
draw2.winners.map(w => w.participant.platformUserId)
);
expect(draw1.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
draw2.reserveWinners.map(w => w.participant.platformUserId)
);
expect(draw1.verificationSignature).toBe(draw2.verificationSignature);
});
it('should produce different winners when seed changes', () => {
const participants = createMockEligibleParticipants(100);
const seedA = 'seed-alpha-123';
const seedB = 'seed-beta-456';
const drawA = executeDeterministicDraw({
giveawayId: 'gw-a',
eligibleParticipants: participants,
totalLoadedCount: 100,
winnersCount: 5,
reserveWinnersCount: 2,
seed: seedA,
filterRules: DEFAULT_FILTER_RULES,
});
const drawB = executeDeterministicDraw({
giveawayId: 'gw-b',
eligibleParticipants: participants,
totalLoadedCount: 100,
winnersCount: 5,
reserveWinnersCount: 2,
seed: seedB,
filterRules: DEFAULT_FILTER_RULES,
});
const winnersA = drawA.winners.map(w => w.participant.platformUserId);
const winnersB = drawB.winners.map(w => w.participant.platformUserId);
expect(winnersA).not.toEqual(winnersB);
});
it('should guarantee no duplicates between winners and reserve winners', () => {
const participants = createMockEligibleParticipants(30);
const seed = generateRandomSeed();
const draw = executeDeterministicDraw({
giveawayId: 'gw-uniq',
eligibleParticipants: participants,
totalLoadedCount: 30,
winnersCount: 5,
reserveWinnersCount: 5,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
const allChosenIds = [
...draw.winners.map(w => w.participant.platformUserId),
...draw.reserveWinners.map(w => w.participant.platformUserId),
];
const uniqueIds = new Set(allChosenIds);
expect(uniqueIds.size).toBe(10);
});
it('should handle cases where pool size is smaller than requested winners', () => {
const participants = createMockEligibleParticipants(2);
const seed = 'small-pool-seed';
const draw = executeDeterministicDraw({
giveawayId: 'gw-small',
eligibleParticipants: participants,
totalLoadedCount: 2,
winnersCount: 5,
reserveWinnersCount: 3,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
expect(draw.winners.length).toBe(2);
expect(draw.reserveWinners.length).toBe(0);
});
it('should throw when attempting draw with 0 eligible participants', () => {
expect(() => {
executeDeterministicDraw({
giveawayId: 'gw-empty',
eligibleParticipants: [],
totalLoadedCount: 0,
winnersCount: 1,
reserveWinnersCount: 0,
seed: 'empty-seed',
filterRules: DEFAULT_FILTER_RULES,
});
}).toThrow(/Cannot conduct draw with 0 eligible participants/);
});
it('should be independent of input participant ordering (canonical sorting)', () => {
const p1 = createMockEligibleParticipants(20);
const p2 = [...p1].reverse(); // reversed order
const seed = 'sort-order-invariant-seed';
const draw1 = executeDeterministicDraw({
giveawayId: 'gw-sort',
eligibleParticipants: p1,
totalLoadedCount: 20,
winnersCount: 3,
reserveWinnersCount: 1,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
const draw2 = executeDeterministicDraw({
giveawayId: 'gw-sort',
eligibleParticipants: p2,
totalLoadedCount: 20,
winnersCount: 3,
reserveWinnersCount: 1,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
expect(draw1.participantsSnapshotHash).toBe(draw2.participantsSnapshotHash);
expect(draw1.winners.map(w => w.participant.platformUserId)).toEqual(
draw2.winners.map(w => w.participant.platformUserId)
);
});
it('should allow third-party verification through verifyDrawResult', () => {
const participants = createMockEligibleParticipants(25);
const seed = 'audit-verification-seed';
const originalDraw = executeDeterministicDraw({
giveawayId: 'gw-audit',
eligibleParticipants: participants,
totalLoadedCount: 25,
winnersCount: 2,
reserveWinnersCount: 2,
seed,
filterRules: DEFAULT_FILTER_RULES,
});
const verification = verifyDrawResult(participants, seed, 2, 2);
expect(verification.snapshotHash).toBe(originalDraw.participantsSnapshotHash);
expect(verification.winners.map(w => w.participant.platformUserId)).toEqual(
originalDraw.winners.map(w => w.participant.platformUserId)
);
expect(verification.reserveWinners.map(w => w.participant.platformUserId)).toEqual(
originalDraw.reserveWinners.map(w => w.participant.platformUserId)
);
});
});

38
tests/vk-parser.test.ts Normal file
View file

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { parseVkPostUrl } from '../src/providers/vk/vk-parser';
describe('VK Post URL Parser', () => {
it('should parse standard desktop URLs', () => {
const res = parseVkPostUrl('https://vk.com/wall-22446688_1054');
expect(res).toEqual({ ownerId: '-22446688', postId: '1054' });
});
it('should parse mobile URLs', () => {
const res = parseVkPostUrl('https://m.vk.com/wall-123456_789');
expect(res).toEqual({ ownerId: '-123456', postId: '789' });
});
it('should parse vk.ru domain URLs', () => {
const res = parseVkPostUrl('https://vk.ru/wall55555_999');
expect(res).toEqual({ ownerId: '55555', postId: '999' });
});
it('should parse URLs with ?w= query parameter', () => {
const res = parseVkPostUrl('https://vk.com/club1234567?w=wall-1234567_42');
expect(res).toEqual({ ownerId: '-1234567', postId: '42' });
});
it('should parse direct wall string format', () => {
const res1 = parseVkPostUrl('wall-100_200');
expect(res1).toEqual({ ownerId: '-100', postId: '200' });
const res2 = parseVkPostUrl('-100_200');
expect(res2).toEqual({ ownerId: '-100', postId: '200' });
});
it('should return null for invalid inputs', () => {
expect(parseVkPostUrl('')).toBeNull();
expect(parseVkPostUrl('https://google.com')).toBeNull();
expect(parseVkPostUrl('invalid_string')).toBeNull();
});
});

27
tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

14
vitest.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});