feat: initial commit - VK Giveaway Randomizer Phase 1
This commit is contained in:
commit
02920a8743
41 changed files with 6967 additions and 0 deletions
14
.env.example
Normal file
14
.env.example
Normal 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
38
.gitignore
vendored
Normal 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
107
README.md
Normal 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
18
docker-compose.yml
Normal 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
59
docs/ARCHITECTURE.md
Normal 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
92
docs/DATA_MODEL.md
Normal 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
130
docs/VK_API_RESEARCH.md
Normal 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
22
next.config.mjs
Normal 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
3146
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
106
prisma/schema.prisma
Normal file
106
prisma/schema.prisma
Normal 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
|
||||
}
|
||||
53
src/app/api/giveaways/[id]/draw/route.ts
Normal file
53
src/app/api/giveaways/[id]/draw/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
49
src/app/api/giveaways/[id]/participants/route.ts
Normal file
49
src/app/api/giveaways/[id]/participants/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
20
src/app/api/giveaways/[id]/route.ts
Normal file
20
src/app/api/giveaways/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
src/app/api/giveaways/route.ts
Normal file
36
src/app/api/giveaways/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/app/api/posts/preview/route.ts
Normal file
31
src/app/api/posts/preview/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
214
src/app/giveaways/[id]/page.tsx
Normal file
214
src/app/giveaways/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
904
src/app/giveaways/new/page.tsx
Normal file
904
src/app/giveaways/new/page.tsx
Normal 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
49
src/app/globals.css
Normal 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
75
src/app/layout.tsx
Normal 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
212
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/core/filtering/filter-engine.ts
Normal file
119
src/core/filtering/filter-engine.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
158
src/core/randomizer/deterministic.ts
Normal file
158
src/core/randomizer/deterministic.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
src/core/randomizer/hasher.ts
Normal file
39
src/core/randomizer/hasher.ts
Normal 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
35
src/core/types/audit.ts
Normal 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;
|
||||
}
|
||||
44
src/core/types/giveaway.ts
Normal file
44
src/core/types/giveaway.ts
Normal 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;
|
||||
}
|
||||
36
src/core/types/participant.ts
Normal file
36
src/core/types/participant.ts
Normal 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
102
src/lib/giveaway-store.ts
Normal 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
13
src/lib/prisma.ts
Normal 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
41
src/providers/registry.ts
Normal 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
38
src/providers/types.ts
Normal 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>>;
|
||||
}
|
||||
117
src/providers/vk/vk-mock-provider.ts
Normal file
117
src/providers/vk/vk-mock-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
src/providers/vk/vk-parser.ts
Normal file
44
src/providers/vk/vk-parser.ts
Normal 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;
|
||||
}
|
||||
258
src/providers/vk/vk-provider.ts
Normal file
258
src/providers/vk/vk-provider.ts
Normal 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
29
tailwind.config.ts
Normal 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
197
tests/filter-engine.test.ts
Normal 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
202
tests/randomizer.test.ts
Normal 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
38
tests/vk-parser.test.ts
Normal 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
27
tsconfig.json
Normal 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
14
vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue