Initial migration: /Users/telecom/dev/_hack24apr/RashidulyN

Auto-migrated to Gitea on 2026-05-17. Secrets, node_modules, build artifacts, and data files excluded via .gitignore.
This commit is contained in:
ask 2026-05-17 10:03:38 +05:00
commit 8491537195
57 changed files with 4316 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# secrets
.env
.env.local
.env.production
.env.development
.jwt-secret
credentials*.json
secrets.json
.npmrc
*.pem
*.key
id_rsa*
id_ed25519*
# build artifacts
node_modules/
.venv/
venv/
__pycache__/
*.pyc
.next/
dist/
build/
out/
target/
# data
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.db
*.log
# OS / IDE
.DS_Store
Thumbs.db
.claude/
.vscode/
.idea/

95
PRD.md Normal file
View File

@ -0,0 +1,95 @@
# PRD — Lead Scoring App для B2B продаж
## Суть продукта
Инструмент для менеджеров по продажам: добавляешь лида, отвечаешь на 6–8 вопросов, система автоматически выставляет score от 0 до 100 и говорит что делать дальше. Работает для входящих (inbound) и исходящих (outbound) лидов.
---
## Пользователи
- **Менеджер по продажам** — основной пользователь, добавляет и оценивает лидов
- **РОП / руководитель** — просматривает список, следит за качеством воронки
---
## Критерии скоринга
| Критерий | Inbound | Outbound | Макс. баллов |
|---|---|---|---|
| Размер компании | ✅ | ✅ | 25 |
| Подтверждён бюджет | ✅ | ✅ | 25 |
| Срочность / дедлайн | ✅ | ✅ | 20 |
| Контакт — ЛПР? | ✅ | ✅ | 15 |
| Источник лида | ✅ | — | 10 |
| Предыдущий контакт | — | ✅ | 10 |
| Соответствие ICP | ✅ | ✅ | 5 |
| **Итого** | | | **100** |
### Детальная логика баллов
**Размер компании**
- 1–10 сотрудников → 5 баллов
- 11–50 сотрудников → 15 баллов
- 50+ сотрудников → 25 баллов
**Бюджет**
- Нет → 0 баллов
- Возможно → 12 баллов
- Подтверждён → 25 баллов
**Срочность**
- Нет дедлайна → 0 баллов
- 3–6 месяцев → 10 баллов
- До 3 месяцев → 20 баллов
**Контакт — ЛПР?**
- Нет → 0 баллов
- Влияет на решение → 8 баллов
- Принимает решение → 15 баллов
**Источник лида (только inbound)**
- Холодный → 3 балла
- Реферал → 7 баллов
- Сайт / входящий → 10 баллов
**Предыдущий контакт (только outbound)**
- Не было → 0 баллов
- Встречались один раз → 5 баллов
- Знакомы хорошо → 10 баллов
---
## Пороговые значения
| Диапазон | Статус | Действие |
|---|---|---|
| 🔴 0–40 | Холодный | Не трать время сейчас |
| 🟡 41–70 | Тёплый | Nurture, следи за активностью |
| 🟢 71–100 | Горячий | Звони сегодня |
---
## Экраны приложения
### 1. Список лидов
- Таблица со всеми лидами
- Цветной бейдж со score у каждого лида
- Сортировка по баллам (по убыванию по умолчанию)
- Фильтр по статусу: Горячий / Тёплый / Холодный
- Фильтр по типу: Inbound / Outbound
- Кнопка "Добавить лида"
### 2. Форма добавления лида
- Имя контакта
- Название компании
- Тип лида: Inbound / Outbound (переключатель)
- 6 вопросов скоринга — **кнопки выбора, не текстовые поля** (быстро заполнять)
- Автоматический расчёт score при отправке
### 3. Карточка лида
- Итоговый score с цветовой индикацией
- Расшифровка: сколько баллов по каждому критерию
- Рекомендованный следующий шаг
- Кнопка редактирования
- История изменений score

452
TRD.md Normal file
View File

@ -0,0 +1,452 @@
# TRD — Lead Scoring App
Технические требования к реализации продукта, описанного в [PRD.md](./PRD.md). Деплой — на инфраструктуру, описанную в [Zorro.md](./Zorro.md).
---
## 1. Архитектура (high-level)
```
browser
│ HTTPS
▼
Traefik ──► nginx (:80) ──► / ──► frontend :3000 (Vue 3 SPA, nginx static)
└─ /api/ ──► backend :8080 (Node.js + Fastify)
│
▼
postgres :5432
```
- Один домен: `leadscore.zorro.kt` (также покрыт `.zorro.kz`, `.telecom.quest` вайлдкардами).
- Один Traefik-роутер, внутри compose — nginx-прокси (паттерн из Zorro.md §«Несколько сервисов на одном домене»).
- ZorroBPM **не используется**.2 Только Dokploy + Traefik как инфраструктура.
---
## 2. Стек
| Слой | Технология | Обоснование |
|---|---|---|
| Frontend | Vue 3 + Vite + TypeScript + Pinia + Vue Router | Стандарт Zorro |
| UI | Tailwind CSS | Быстрая разметка, не тянет компонентную либу |
| Backend | Node.js 22 + Fastify + TypeScript | Решение пользователя |
| DB | PostgreSQL 16 | В docker-compose |
| Migrations | `node-pg-migrate` | Простой SQL-first инструмент |
| Auth | JWT (jsonwebtoken) + bcrypt | Захардкоженные юзеры |
| HTTP client (front) | fetch + тонкий враппер | Без axios |
| Deploy | Docker + Dokploy | По Zorro.md |
---
## 3. Модель данных (Postgres)
### Таблица `leads`
```sql
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_name TEXT NOT NULL,
company_name TEXT NOT NULL,
lead_type TEXT NOT NULL CHECK (lead_type IN ('inbound', 'outbound')),
-- ответы на вопросы скоринга
company_size TEXT NOT NULL CHECK (company_size IN ('1-10', '11-50', '50+')),
budget TEXT NOT NULL CHECK (budget IN ('no', 'maybe', 'confirmed')),
urgency TEXT NOT NULL CHECK (urgency IN ('none', '3-6m', 'under-3m')),
dm_contact TEXT NOT NULL CHECK (dm_contact IN ('no', 'influencer', 'decision-maker')),
source TEXT CHECK (source IN ('cold', 'referral', 'website')), -- только inbound
prev_contact TEXT CHECK (prev_contact IN ('none', 'once', 'well-known')), -- только outbound
icp_fit BOOLEAN NOT NULL,
-- computed
score INT NOT NULL CHECK (score BETWEEN 0 AND 100),
status TEXT NOT NULL CHECK (status IN ('cold', 'warm', 'hot')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL,
-- бизнес-правило: inbound ⇒ source задан, outbound ⇒ prev_contact задан
CONSTRAINT lead_type_fields CHECK (
(lead_type = 'inbound' AND source IS NOT NULL AND prev_contact IS NULL) OR
(lead_type = 'outbound' AND prev_contact IS NOT NULL AND source IS NULL)
)
);
CREATE INDEX leads_score_desc ON leads (score DESC);
CREATE INDEX leads_status ON leads (status);
CREATE INDEX leads_lead_type ON leads (lead_type);
```
### Таблица `score_history`
```sql
CREATE TABLE score_history (
id BIGSERIAL PRIMARY KEY,
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
total INT NOT NULL,
breakdown JSONB NOT NULL, -- {company_size: 15, budget: 25, ...}
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
changed_by TEXT NOT NULL
);
CREATE INDEX score_history_lead ON score_history (lead_id, changed_at DESC);
```
Запись в `score_history` добавляется на каждый `POST /leads` и каждый `PATCH /leads/:id`, который меняет score.
### Пользователи
Захардкожены в env бэкенда — таблицы нет.
```
AUTH_USERS=manager:$2b$10$...,rop:$2b$10$...
JWT_SECRET=<random-32-bytes-base64>
```
Логины: `manager`, `rop`. Пароли выдаются хешами bcrypt через env. Роль = username.
---
## 4. Логика скоринга (бэкенд)
Чистая функция `calculateScore(input) → { total, breakdown, status }`. Живёт в `backend/src/scoring.ts`. Покрывается unit-тестами.
```ts
type ScoringInput = {
lead_type: 'inbound' | 'outbound';
company_size: '1-10' | '11-50' | '50+';
budget: 'no' | 'maybe' | 'confirmed';
urgency: 'none' | '3-6m' | 'under-3m';
dm_contact: 'no' | 'influencer' | 'decision-maker';
source?: 'cold' | 'referral' | 'website'; // только inbound
prev_contact?: 'none' | 'once' | 'well-known'; // только outbound
icp_fit: boolean;
};
type ScoringResult = {
total: number; // 0..100
breakdown: Record<string, number>; // каждый критерий → баллы
status: 'cold' | 'warm' | 'hot';
};
```
### Таблицы баллов (из PRD §«Детальная логика»)
| Критерий | Значения → баллы |
|---|---|
| `company_size` | `1-10`→5, `11-50`→15, `50+`→25 |
| `budget` | `no`→0, `maybe`→12, `confirmed`→25 |
| `urgency` | `none`→0, `3-6m`→10, `under-3m`→20 |
| `dm_contact` | `no`→0, `influencer`→8, `decision-maker`→15 |
| `source` (inbound) | `cold`→3, `referral`→7, `website`→10 |
| `prev_contact` (outbound) | `none`→0, `once`→5, `well-known`→10 |
| `icp_fit` | `true`→5, `false`→0 |
### Пороги статуса
- `total ≤ 40` → `cold`
- `41 ≤ total ≤ 70` → `warm`
- `total ≥ 71` → `hot`
### Валидация входа
- `lead_type === 'inbound'` → `source` обязателен, `prev_contact` запрещён.
- `lead_type === 'outbound'` → `prev_contact` обязателен, `source` запрещён.
Нарушение → `400 VALIDATION_ERROR`.
---
## 5. Backend API
Базовый префикс — `/api`. Все эндпоинты (кроме `/auth/login`) требуют `Authorization: Bearer <jwt>`. Content-type — `application/json`.
### Auth
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/auth/login` | body: `{username, password}` → `{token, user: {username, role}}`. 401 при неверном пароле. |
JWT payload: `{sub: username, role: username, iat, exp}`. TTL токена — 12 часов.
### Leads
| Метод | Путь | Кто | Описание |
|---|---|---|---|
| GET | `/api/leads` | любой | Список. Query: `status`, `lead_type`, `sort` (default `score_desc`), `limit`, `offset`. Возвращает `{data: Lead[], total}`. |
| POST | `/api/leads` | любой | Создать. Body — форма PRD §2 + 6 ответов. Score считается на сервере, игнорируется если пришёл в теле. Пишет запись в `score_history`. |
| GET | `/api/leads/:id` | любой | Полная карточка (lead + последний breakdown). |
| PATCH | `/api/leads/:id` | любой | Редактирование. Пересчитывает score. Если score меняется → запись в `score_history`. |
| GET | `/api/leads/:id/history` | любой | История изменений score. |
| DELETE | `/api/leads/:id` | только `rop` | Удалить. |
Роль `rop` отличается только правом на DELETE (задел; если не нужно — скажи, выкинем).
### Ответ лида (пример)
```json
{
"id": "a1b2…",
"contact_name": "Иван Петров",
"company_name": "ООО Ромашка",
"lead_type": "inbound",
"answers": {
"company_size": "50+", "budget": "confirmed", "urgency": "under-3m",
"dm_contact": "decision-maker", "source": "website", "icp_fit": true
},
"score": 100,
"status": "hot",
"breakdown": {
"company_size": 25, "budget": 25, "urgency": 20,
"dm_contact": 15, "source": 10, "icp_fit": 5
},
"created_at": "2026-04-24T10:00:00Z",
"updated_at": "2026-04-24T10:00:00Z",
"created_by": "manager"
}
```
### Формат ошибок
```json
{ "error": { "code": "VALIDATION_ERROR", "message": "…", "fields": {…} } }
```
Коды: `VALIDATION_ERROR` (400), `UNAUTHORIZED` (401), `FORBIDDEN` (403), `NOT_FOUND` (404), `INTERNAL` (500).
---
## 6. Frontend
### Роуты
| Путь | View | Доступ |
|---|---|---|
| `/login` | `LoginView.vue` | без токена |
| `/` | `LeadsListView.vue` | auth |
| `/leads/new` | `NewLeadView.vue` | auth |
| `/leads/:id` | `LeadCardView.vue` | auth |
Если токен отсутствует или просрочен — редирект на `/login`.
### Структура
```
frontend/
├── Dockerfile
├── nginx.conf (SPA fallback на /index.html)
├── index.html
├── vite.config.ts
├── tailwind.config.js
├── package.json
└── src/
├── main.ts
├── App.vue
├── router.ts
├── api/
│ ├── client.ts (fetch + token header + 401 handler)
│ ├── auth.ts
│ └── leads.ts
├── stores/
│ ├── auth.ts (Pinia: token, user, login(), logout())
│ └── leads.ts (Pinia: список, фильтры)
├── components/
│ ├── ScoreBadge.vue (цвет по статусу)
│ ├── FilterBar.vue
│ └── QuestionButton.vue (radio-группа «кнопка выбора»)
└── views/
├── LoginView.vue
├── LeadsListView.vue
├── NewLeadView.vue
└── LeadCardView.vue
```
### Ключевые UI-требования (из PRD)
- Бейдж цвета: 🔴 cold / 🟡 warm / 🟢 hot.
- Сортировка списка по score DESC по умолчанию.
- Фильтры: `status` (cold/warm/hot/all), `lead_type` (inbound/outbound/all).
- В форме — **только кнопки выбора** (radio-группы, никаких текстовых полей для ответов). Имя и компания — текст.
- Поля формы, зависящие от `lead_type`, показываются/скрываются реактивно: inbound → поле «Источник», outbound → «Предыдущий контакт».
- На карточке лида — breakdown по каждому критерию (баллы) + рекомендация (`cold`: «Не трать время сейчас», `warm`: «Nurture, следи за активностью», `hot`: «Звони сегодня»).
### API base URL
Берётся из `VITE_API_BASE` (в Docker — `/api`, так как nginx проксирует).
---
## 7. Deployment
### Структура репозитория
```
leadscore/
├── frontend/ (Vue 3 + Dockerfile)
├── backend/ (Node + Dockerfile)
├── nginx/
│ └── default.conf (роутинг / → frontend, /api/ → backend)
├── migrations/ (SQL-миграции для node-pg-migrate)
└── docker-compose.yaml
```
### `docker-compose.yaml`
```yaml
services:
frontend:
build: ./frontend # nginx:alpine serves dist/
networks: [internal]
restart: unless-stopped
backend:
build: ./backend # node:22-alpine, node dist/server.js
networks: [internal]
environment:
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
JWT_SECRET: ${JWT_SECRET}
AUTH_USERS: ${AUTH_USERS}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:16-alpine
networks: [internal]
environment:
POSTGRES_USER: leadscore
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: leadscore
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U leadscore"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
migrate:
build: ./backend
command: npm run migrate:up
networks: [internal]
environment:
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
depends_on:
postgres:
condition: service_healthy
restart: "no"
nginx:
image: nginx:alpine
networks: [internal, dokploy-network]
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [frontend, backend]
labels:
- traefik.enable=true
- traefik.http.routers.leadscore-http.rule=HostRegexp("leadscore.+")
- traefik.http.routers.leadscore-http.entrypoints=web
- traefik.http.routers.leadscore-http.service=leadscore
- traefik.http.routers.leadscore-http.priority=20
- traefik.http.routers.leadscore-https.rule=HostRegexp("leadscore.+")
- traefik.http.routers.leadscore-https.entrypoints=websecure
- traefik.http.routers.leadscore-https.tls=true
- traefik.http.routers.leadscore-https.service=leadscore
- traefik.http.routers.leadscore-https.priority=20
- traefik.http.services.leadscore.loadbalancer.server.port=80
restart: unless-stopped
volumes:
pgdata:
networks:
internal:
dokploy-network:
external: true
```
### `nginx/default.conf`
```nginx
server {
listen 80;
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
location / {
proxy_pass http://frontend:80/;
proxy_set_header Host $host;
}
}
```
### Секреты в Dokploy
В Dokploy UI (Project → Environment) задать:
- `DB_PASSWORD` — случайные 32 символа
- `JWT_SECRET` — случайные 32 байта base64
- `AUTH_USERS` — `manager:<bcrypt>,rop:<bcrypt>`
Хеши bcrypt генерируются один раз локально:
```bash
node -e "console.log(require('bcrypt').hashSync('secret', 10))"
```
### Процедура деплоя (по Zorro.md §6)
1. Запушить репо в git.
2. Dokploy → New Project → `leadscore` → Create Service → Compose → Git URL.
3. Environment → вставить секреты.
4. Domains → `leadscore.zorro.kt`.
5. Deploy.
Миграции катятся сервисом `migrate` при каждом старте (идемпотентно).
---
## 8. Не-функциональные требования
- **Производительность.** До 10 000 лидов в БД. Список — с пагинацией (limit 50 по умолчанию), индекс по `score DESC` + фильтры.
- **Безопасность.**
- JWT в `Authorization: Bearer`. Frontend хранит в `localStorage` (MVP; предупреждение в readme, что XSS снимет токен).
- bcrypt cost 10.
- Параметризованные SQL-запросы (через `pg` driver). Никакой конкатенации.
- CORS: бэкенд открыт только для same-origin (запросы идут через nginx под одним доменом) — `origin: false` в Fastify.
- **Логи.** Pino (default Fastify) → stdout → Docker logs. Уровень `info`.
- **Health check.** `GET /api/health` → `{ok: true}`. Nginx не проксирует Traefik-health отдельно — достаточно 200 с корня.
- **Ошибки.** Любой необработанный throw → 500 + лог со stack trace, юзеру — `{error: {code: 'INTERNAL', message: 'Internal error'}}`.
- **i18n.** Весь UI на русском, хардкод. Без i18n-либы.
- **Тесты.**
- Backend: `scoring.test.ts` — unit на все веса и пороги; `leads.int.test.ts` — интеграционный (supertest) через testcontainers-postgres.
- Frontend: smoke-тесты через Vitest на ключевые компоненты (ScoreBadge, QuestionButton). E2E не делаем.
---
## 9. Вне скоупа MVP
- Регистрация, сброс пароля, многопользовательские роли кроме `manager`/`rop`.
- Интеграции (CRM, email, Slack).
- Экспорт/импорт CSV.
- Real-time обновления списка (WebSocket/SSE).
- Мобильное приложение.
- Notifications/напоминания.
---
## 10. Acceptance criteria
- Менеджер логинится, создаёт лида (6 ответов), видит score + статус, они соответствуют таблицам §4.
- Список сортируется по score DESC, фильтры по статусу и типу работают.
- Редактирование лида пересчитывает score и добавляет запись в history; история видна на карточке.
- РОП может удалить лида; менеджер — нет (403).
- После ребута сервера — данные сохранились (postgres volume).
- Продакшн-URL `https://leadscore.zorro.kt` отдаёт приложение.

726
Zorro.md Normal file
View File

@ -0,0 +1,726 @@
# ZORRO — гайд разработчика на ZorroBPM
Практический путь «с нуля до задеплоенного процесса и рабочего UI». На этом Mac Studio **ZorroBPM уже поднят** — ваша задача: накидать процесс, прогнать тесты, сделать фронт + воркеры, задеплоить всё через Dokploy, получить свой `myapp.zorro.kt`.
> Архитектура, API, сети — см. [ZORRO_SERVER.md](/Users/telecom/Dev/_Zorro/ZORRO_SERVER.md).
> API reference — [zorrobpm-server/API.md](/Users/telecom/Dev/_Zorro/zorrobpm-server/API.md).
---
## Что уже развёрнуто (не надо поднимать)
| Сервис | URL | Доступ |
|---|---|---|
| ZorroBPM REST API (из Docker) | `http://zorrobpm-bqbw6o-api-1` | сеть `zorrobpm-bqbw6o_default` |
| ZorroBPM REST API (с Mac) | `http://localhost:9092` | прямой порт хоста |
| Swagger | `http://localhost:9092/swagger-ui/index.html` | открывать в браузере |
| Админка ZorroBPM | `http://localhost:9091` | открывать в браузере |
| RabbitMQ AMQP (из Docker) | `amqp://demo:demo@zorrobpm-bqbw6o-rabbitmq-1:5672` | сеть `zorrobpm-bqbw6o_default` |
| Dokploy (PaaS) | https://dokploy.zorro.kt | логин заведён в UI |
| code-server (VS Code) | https://code.zorro.kt | password `zorro` |
---
## Правила платформы — читать обязательно
> **На хосте (Mac Studio) запрещено запускать любые сервисы напрямую.**
> Никакого `node index.js`, `npm run dev`, `python app.py`, `./server` и т.п. прямо в терминале.
> Фронтенд, бэкенд, воркер, база данных — **всё только в Docker-контейнерах**, задеплоенных через Dokploy.
Причина: сервер общий. Процессы на хосте конфликтуют по портам, не перезапускаются после ребута, не изолированы друг от друга.
### Соглашение об именах: папка = субдомен
Имя папки вашего проекта становится именем субдомена. Если проект лежит в папке `vacation-app/` — его домены:
| Домен | Wildcard-покрытие |
|---|---|
| `vacation-app.zorro.kt` | `*.zorro.kt` |
| `vacation-app.zorro.kz` | `*.zorro.kz` |
| `vacation-app.telecom.quest` | `*.telecom.quest` |
DNS и TLS-сертификаты для всех трёх вайлдкардов уже настроены. Чтобы покрыть все три домена одним правилом Traefik используется `HostRegexp("vacation-app.+")` — регекс матчит `vacation-app.` + любой суффикс.
### Шаблон docker-compose для фронта (Dokploy-ready)
Замените `vacation-app` на имя своей папки везде в labels.
> **Важно:** в стеке два пути трафика:
> - `*.zorro.kt` — прямые HTTPS подключения → Traefik `websecure` entrypoint
> - `*.zorro.kz`, `*.telecom.quest` — через Cloudflare tunnel, который шлёт **plain HTTP** → Traefik `web` entrypoint
>
> Роутер с `tls=true` не срабатывает на plain HTTP. Поэтому нужны **два отдельных роутера**: один для `web` (без tls), один для `websecure` (с tls). Иначе tunnel-домены попадут на catch-all `zorro-site`.
```yaml
services:
frontend:
build: .
networks: [dokploy-network]
labels:
- traefik.enable=true
# HTTP — Cloudflare tunnel шлёт plain HTTP на port 80
- traefik.http.routers.vacation-app-http.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-http.entrypoints=web
- traefik.http.routers.vacation-app-http.service=vacation-app
- traefik.http.routers.vacation-app-http.priority=20
# HTTPS — прямые подключения (*.zorro.kt)
- traefik.http.routers.vacation-app-https.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-https.entrypoints=websecure
- traefik.http.routers.vacation-app-https.tls=true
- traefik.http.routers.vacation-app-https.service=vacation-app
- traefik.http.routers.vacation-app-https.priority=20
- traefik.http.services.vacation-app.loadbalancer.server.port=80
networks:
dokploy-network:
external: true
```
Воркеры и другие внутренние сервисы в `dokploy-network` не идут — только в `zorrobpm_default`. Traefik-метки нужны только тому, что смотрит наружу.
### Несколько сервисов на одном домене — nginx-прокси
Если у вас есть фронт (порт 3000) и бэкенд (порт 8080) и вы хотите выдать их на одном домене (`vacation-app.zorro.kt/` и `vacation-app.zorro.kt/api/`), **не регистрируйте в Traefik каждый порт отдельно**. Поставьте nginx-роутер внутри compose и пробросьте в Traefik только его:
```
Traefik → nginx:80 → / → frontend:3000
→ /api/ → backend:8080
```
```yaml
services:
frontend:
build: ./frontend # слушает :3000
networks: [internal]
backend:
build: ./backend # слушает :8080
networks: [internal]
nginx:
image: nginx:alpine
networks: [internal, dokploy-network]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
labels:
- traefik.enable=true
- traefik.http.routers.vacation-app-http.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-http.entrypoints=web
- traefik.http.routers.vacation-app-http.service=vacation-app
- traefik.http.routers.vacation-app-http.priority=20
- traefik.http.routers.vacation-app-https.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-https.entrypoints=websecure
- traefik.http.routers.vacation-app-https.tls=true
- traefik.http.routers.vacation-app-https.service=vacation-app
- traefik.http.routers.vacation-app-https.priority=20
- traefik.http.services.vacation-app.loadbalancer.server.port=80
networks:
internal: # внутренняя сеть, наружу не видна
dokploy-network:
external: true
```
`nginx.conf`:
```nginx
server {
listen 80;
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
}
location / {
proxy_pass http://frontend:3000/;
proxy_set_header Host $host;
}
}
```
Правило: **один домен = один Traefik-роутер = один сервис на порту 80**. Всё остальное — за nginx внутри.
---
## Маршрут за 7 шагов
```
[1] Нарисовать BPMN-процесс (bpmn.io / Camunda Modeler)
↓
[2] Embedded-тесты через zorrobpm-examples (H2, локально, быстро)
↓
[3] Задеплоить BPMN в движок (localhost:9092 или zorrobpm-bqbw6o-api-1)
↓
[4] Vue.js 3 фронтенд (Docker) ← общается с /process-instances, /user-tasks
↓
[5] Node.js воркер(ы) (Docker) ← сидят на zorrobpm.jobs.<job>
↓
[6] Всё собрать в один docker-compose, deploy через Dokploy
↓
[7] Замапить myapp.zorro.kt на фронт (Traefik-метки)
```
Ниже — каждый шаг детально.
---
## Шаг 1. Нарисовать BPMN-процесс
Выбирайте один:
| Инструмент | Где | Плюс |
|---|---|---|
| **bpmn.io демо** | https://demo.bpmn.io | ничего не ставить, прямо в браузере |
| **Camunda Modeler** (desktop) | https://camunda.com/download/modeler/ | offline, самый зрелый редактор |
Простейший процесс «Заявка на отпуск» в BPMN-элементах:
```
Start ──→ [Форма заявки] ──→ [serviceTask: notify manager]
userTask (zeebe:taskDefinition type="notify.manager")
─→ [Решение] ──→ End
userTask
```
Сохраните файл `vacation-request.bpmn`. Внутри — XML, начинается с `<bpmn:definitions>`.
**Ключевые атрибуты элементов:**
> Namespace: процессы рисуются в Camunda Modeler в режиме **Camunda Cloud / Zeebe 8**, поэтому в корне `<bpmn:definitions>` должен быть `xmlns:zeebe="http://camunda.org/schema/zeebe/1.0"`. ZorroBPM читает именно Zeebe-атрибуты, не самодельные `zorro:*`.
>
> **Обязательные Zeebe-расширения (подтверждено тестами):**
> - `zeebe:taskDefinition type="..."` — для `serviceTask`, задаёт имя очереди RabbitMQ
> - `zeebe:calledElement processId="..."` — для `callActivity`, задаёт вызываемый процесс; без него NPE в движке
>
> **Не поддерживается:** `zeebe:assignmentDefinition` (candidateGroups/assignee через BPMN). Фильтрация задач — на стороне фронта через параметры REST API.
```xml
<bpmn:process id="vacationRequest" name="Vacation Request" isExecutable="true">
<!-- UserTask — задача человеку, zeebe-расширения не нужны -->
<bpmn:userTask id="Task_FillForm" name="Заполнить заявку">
</bpmn:userTask>
<!-- ServiceTask — задача воркеру через RabbitMQ -->
<bpmn:serviceTask id="Task_Notify" name="Уведомить менеджера">
<bpmn:extensionElements>
<zeebe:taskDefinition type="notify.manager" retries="3" />
</bpmn:extensionElements>
</bpmn:serviceTask>
</bpmn:process>
```
`zeebe:taskDefinition type="notify.manager"` превращается в RabbitMQ очередь `zorrobpm.jobs.notify.manager`. Это тот код, на который подпишется ваш воркер (шаг 5).
> **Важно:** userTask создаётся без привязанного assignee/candidateGroup — кто видит задачу, решает ваш фронт (например, показываете всем авторизованным пользователям или фильтруете по переменной процесса через `GET /user-tasks?processInstanceId=...`).
Референсы реальных BPMN: `/Users/telecom/Dev/_Zorro/zorrobpm-examples/src/main/resources/examples/` — смотрите `childProcess.bpmn` (serviceTask) и `approvalRouteProcess.bpmn` (callActivity + gateway).
---
## Шаг 2. Embedded-тесты (H2, быстро, без деплоя)
Прогон embedded-тестов на встроенном движке с H2 in-memory БД — **не требует работающего ZorroBPM**. Используется для unit-проверки логики процесса (переходы по gateways, variables).
Положите ваш `.bpmn` в `/Users/telecom/Dev/_Zorro/zorrobpm-examples/src/main/resources/examples/vacation/`:
```bash
mkdir -p /Users/telecom/Dev/_Zorro/zorrobpm-examples/src/main/resources/examples/vacation
cp vacation-request.bpmn /Users/telecom/Dev/_Zorro/zorrobpm-examples/src/main/resources/examples/vacation/
```
Напишите тест по образцу `CallActivityTest.java`:
```java
// src/test/java/kz/telecom/bpms/examples/vacation/VacationProcessTest.java
package kz.telecom.bpms.examples.vacation;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
class VacationProcessTest {
@Autowired RepositoryService repositoryService;
@Autowired RuntimeService runtimeService;
@Autowired TaskService taskService;
@Test
void happyPath_approved() {
// 1. deploy
var def = repositoryService.createDeployment()
.addClasspathResource("examples/vacation/vacation-request.bpmn")
.deploy();
// 2. start instance
var pi = runtimeService.startProcessInstanceByKey("vacationRequest");
assertThat(pi).isNotNull();
// 3. заполнили заявку
// ZorroBPM не читает zeebe:assignmentDefinition — candidateGroup в BPMN не задаётся,
// ищем задачу по processInstanceId + имени
var formTask = taskService.createTaskQuery()
.processInstanceId(pi.getId())
.taskName("Заполнить заявку")
.singleResult();
assertThat(formTask).isNotNull();
taskService.complete(formTask.getId(), Map.of("days", 5));
// 4. serviceTask "notify.manager" отрабатывает автоматически (mock в тесте)
// 5. менеджер одобрил
var decision = taskService.createTaskQuery()
.processInstanceId(pi.getId())
.taskName("Решение")
.singleResult();
taskService.complete(decision.getId(), Map.of("action", "approve"));
// 6. процесс завершился
assertThat(runtimeService.createProcessInstanceQuery().processInstanceId(pi.getId()).count()).isZero();
}
}
```
Запуск:
```bash
cd /Users/telecom/Dev/_Zorro/zorrobpm-examples
docker-compose up -d # одноразово, поднимает maven dev-контейнер
./test.sh # embedded mode → прогоняет все *Test.java против H2
```
Тест красный → правите BPMN → повторяете. Fast-feedback loop, боевой сервер трогать не надо.
> **Переменные в тестах.** Чтобы проверить что процесс получил правильные переменные — передавайте их при complete и читайте из processInstance. Пример как вернуть переменные из воркера и проверить их — см. `/Users/telecom/Dev/_Zorro/zorrobpm-handlers/src/test/java/com/zorrodev/bpm/handler/examle/Handler1Test.java`.
---
## Шаг 3. Задеплоить BPMN в реальный движок
Когда тесты зелёные — льём в движок:
```bash
# через curl с Mac-терминала
BPMN=$(cat vacation-request.bpmn | jq -Rs .)
curl -X POST http://localhost:9092/process-definitions \
-H 'Content-Type: application/json' \
-d "{\"bpmn\": $BPMN}"
# ← {"id":"...","key":"vacationRequest","version":1, ...}
# из другого контейнера (в сети zorrobpm-bqbw6o_default)
curl -X POST http://zorrobpm-bqbw6o-api-1/process-definitions \
-H 'Content-Type: application/json' \
-d "{\"bpmn\": $BPMN}"
```
Или **через Swagger UI**: `http://localhost:9092/swagger-ui/index.html` → `POST /process-definitions` → Try it out.
Или **через админку**: `http://localhost:9091` → Definitions → Deploy → выбрать `.bpmn` файл.
Повторный деплой того же `processDefinitionKey` создаст новую `version`. Старые инстансы продолжат работать по старой версии, новые `startProcessInstance` будут использовать последнюю.
---
## Шаг 4. Фронтенд на Vue.js 3 (в Docker)
Создадим проект `myapp-frontend` — UI где пользователи заполняют форму заявки и видят свои задачи.
### Структура
```
myapp-frontend/
├── Dockerfile
├── docker-compose.yaml
├── nginx.conf # или nginx из базового образа
├── package.json
├── vite.config.ts
├── src/
│ ├── App.vue
│ ├── api/zorrobpm.ts # клиент ZorroBPM REST
│ └── views/
│ ├── NewRequest.vue # форма
│ └── MyTasks.vue # список задач
└── index.html
```
### src/api/zorrobpm.ts — клиент REST API
```ts
// браузер не знает Docker-имена → используем localhost или внешний домен
const API = import.meta.env.VITE_ZORROBPM_API || 'http://localhost:9092';
export async function startRequest(days: number, reason: string) {
const r = await fetch(`${API}/process-instances`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
processDefinitionKey: 'vacationRequest',
variables: [
{ name: 'days', value: String(days), type: 'LONG' },
{ name: 'reason', value: reason, type: 'STRING' },
]
})
});
return (await r.json()).id as string;
}
export async function myTasks(user: string) {
const r = await fetch(`${API}/user-tasks?completed=false&assignee=${user}&pageSize=50`);
return (await r.json()).data;
}
export async function completeTask(taskId: string, action: 'approve' | 'reject') {
await fetch(`${API}/user-tasks/${taskId}/complete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: [{ name: 'action', value: action, type: 'STRING' }] })
});
}
```
### Dockerfile — multi-stage build
```dockerfile
# build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
```
### nginx.conf — SPA-режим
```
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri $uri/ /index.html; }
}
```
**CORS** открыт (wildcard) — фронт дергает `http://localhost:9092` напрямую без прокси. В prod — `https://bpm.zorro.kt`.
### docker-compose.yaml — деплой в сеть Zorro
Имя папки проекта — `vacation-app`, значит субдомен `vacation-app.*`. Labels — прямо на сервисе (не под `deploy:`):
```yaml
services:
frontend:
build: .
networks: [dokploy-network]
labels:
- traefik.enable=true
- traefik.http.routers.vacation-app-http.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-http.entrypoints=web
- traefik.http.routers.vacation-app-http.service=vacation-app
- traefik.http.routers.vacation-app-http.priority=20
- traefik.http.routers.vacation-app-https.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-https.entrypoints=websecure
- traefik.http.routers.vacation-app-https.tls=true
- traefik.http.routers.vacation-app-https.service=vacation-app
- traefik.http.routers.vacation-app-https.priority=20
- traefik.http.services.vacation-app.loadbalancer.server.port=80
networks:
dokploy-network:
external: true
```
---
## Шаг 5. Воркеры (Node.js или Java)
Наш BPMN содержит `<zeebe:taskDefinition type="notify.manager" />` → ZorroBPM пушит сообщения в очередь `zorrobpm.jobs.notify.manager`. Воркер получает → делает работу (напр. отправить email/slack/SMS) → закрывает таск через REST.
Два варианта: **Node.js** (amqplib, REST вручную) или **Java Spring Boot** (SDK берёт на себя очередь и complete).
### Вариант A — Java SDK (рекомендуется для Java-проектов)
Готовый пример: `/Users/telecom/Dev/_Zorro/zorrobpm-handlers/` — скопируйте как основу.
```java
@Slf4j
@Component
public class MyHandler implements JobHandler {
@Override
public String getJob() { return "notify.manager"; }
@Override
public List<ProcessVariable> handleJob(JobDetailModel model) {
// model.getProcessInstanceId(), model.getJob(), ...
// ваша логика (отправить email, записать в БД...)
ProcessVariable result = new ProcessVariable();
result.setName("notified");
result.setType(ProcessVariableType.BOOLEAN);
result.setValue("true");
return List.of(result); // переменные запишутся обратно в процесс
}
}
```
SDK автоматически: подписывается на `zorrobpm.jobs.notify.manager`, вызывает `handleJob()`, вызывает `complete` с вашими переменными.
**Обязательно** в `.mvn/1repo-settings.xml`:
```xml
<settings>
<mirrors>
<mirror>
<id>1repo</id>
<url>https://maven.1repo.com/1repo/all</url>
<mirrorOf>*</mirrorOf>
</mirror>
</mirrors>
</settings>
```
Запуск тестов:
```bash
cd /Users/telecom/Dev/_Zorro/zorrobpm-handlers
./test.sh # embedded (H2, без сервера и RabbitMQ)
```
### Вариант B — Node.js (myapp-worker/index.js)
```javascript
import amqp from 'amqplib';
const RABBIT = process.env.RABBIT_URL || 'amqp://demo:demo@rabbitmq:5672/';
const API = process.env.ZORROBPM_API || 'http://zorrobpm-bqbw6o-api-1'; // внутри zorrobpm_default
const QUEUE = 'zorrobpm.jobs.notify.manager';
const conn = await amqp.connect(RABBIT);
const ch = await conn.createChannel();
await ch.assertQueue(QUEUE, { durable: true });
ch.prefetch(5);
console.log(`worker listening on ${QUEUE}`);
ch.consume(QUEUE, async (msg) => {
const job = JSON.parse(msg.content.toString());
console.log(`[${job.taskId}] notify.manager`, job.variables);
try {
// ... ваша логика: sendEmail(job.variables.days, job.variables.reason) ...
await new Promise(r => setTimeout(r, 500));
// сообщить движку что задача выполнена
const r = await fetch(`${API}/service-tasks/${job.taskId}/complete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: [{ name: 'notified', value: 'true', type: 'BOOLEAN' }] })
});
if (!r.ok) throw new Error(`complete failed: HTTP ${r.status}`);
ch.ack(msg);
} catch (e) {
console.error(e);
ch.nack(msg, false, true); // вернуть в очередь
}
});
```
### Dockerfile
```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "index.js"]
```
### docker-compose.yaml воркера
**Важно:** воркер подключается к `zorrobpm_default` сети (там rabbitmq с алиасом `rabbitmq`). API — тоже там, по имени `api`. Никаких публичных URL не нужно.
```yaml
services:
worker:
build: .
image: myapp-worker:latest
networks:
- zorrobpm_default
environment:
RABBIT_URL: amqp://demo:demo@rabbitmq:5672/
ZORROBPM_API: http://zorrobpm-bqbw6o-api-1
networks:
zorrobpm_default:
external: true
```
Если воркеров много (по одному на job-type) — размножайте сервисы, меняйте `QUEUE`:
```yaml
services:
worker-notify:
build: ./worker-notify
networks: [zorrobpm_default]
worker-sms:
build: ./worker-sms
networks: [zorrobpm_default]
worker-archive:
build: ./worker-archive
networks: [zorrobpm_default]
```
---
## Шаг 6. Собрать всё вместе + Dokploy
Финальная раскладка вашего проекта:
```
myapp/
├── frontend/ (Vue 3 + Dockerfile — шаг 4)
├── worker-notify/ (Node + Dockerfile — шаг 5)
├── bpmn/
│ └── vacation-request.bpmn
└── docker-compose.yaml (общий — поднимает frontend и воркер)
```
### Общий docker-compose.yaml
Папка проекта — `vacation-app`. Фронт идёт в `dokploy-network`, воркер — в `zorrobpm_default`:
```yaml
services:
frontend:
build: ./frontend
networks: [dokploy-network]
labels:
- traefik.enable=true
- traefik.http.routers.vacation-app-http.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-http.entrypoints=web
- traefik.http.routers.vacation-app-http.service=vacation-app
- traefik.http.routers.vacation-app-http.priority=20
- traefik.http.routers.vacation-app-https.rule=HostRegexp("vacation-app.+")
- traefik.http.routers.vacation-app-https.entrypoints=websecure
- traefik.http.routers.vacation-app-https.tls=true
- traefik.http.routers.vacation-app-https.service=vacation-app
- traefik.http.routers.vacation-app-https.priority=20
- traefik.http.services.vacation-app.loadbalancer.server.port=80
worker-notify:
build: ./worker-notify
networks: [zorrobpm_default]
environment:
RABBIT_URL: amqp://demo:demo@rabbitmq:5672/
ZORROBPM_API: http://zorrobpm-bqbw6o-api-1
restart: unless-stopped
networks:
dokploy-network:
external: true
zorrobpm_default:
external: true
```
### Деплой через Dokploy
1. **Войти** в https://dokploy.zorro.kt.
2. **New Project** → имя = имя папки (`vacation-app`).
3. **Create Service** → тип **Compose**.
4. Источник:
- **Git**: указать URL репозитория + ветку. Dokploy склонирует и поднимет.
- **Raw**: вставить содержимое `docker-compose.yaml` напрямую.
5. **Domains** → Add Domain → `vacation-app.zorro.kt` (и/или `.zorro.kz`, `.telecom.quest`). DNS и cert уже покрыты вайлдкардом.
6. **Deploy** → ждёте сборку.
> Деплоить напрямую через `docker compose up` на хосте **запрещено** — только через Dokploy.
### Задеплоить BPMN один раз при первом старте
Можно сделать init-контейнер в compose, который ждёт API и кидает туда BPMN:
```yaml
bpm-deploy:
image: curlimages/curl:latest
volumes:
- ./bpmn:/bpmn:ro
networks: [zorrobpm_default]
command: >
sh -c "until curl -sf http://zorrobpm-bqbw6o-api-1/actuator/health; do sleep 2; done;
BPMN=$$(cat /bpmn/vacation-request.bpmn | sed 's/\"/\\\\\"/g' | tr -d '\n');
curl -X POST http://zorrobpm-bqbw6o-api-1/process-definitions
-H 'Content-Type: application/json'
-d \"{\\\"bpmn\\\":\\\"$$BPMN\\\"}\""
restart: "no"
```
Или проще: после первого деплоя один раз вручную скормить BPMN через Swagger UI или curl со своей машины — и забыть.
---
## Шаг 7. Готово — проверить связку
```bash
# открываем
open http://localhost:9091 # админка — процессы, инстансы, таски
open http://localhost:9092/swagger-ui/index.html # Swagger UI
# смотрим очереди
docker exec zorrobpm-bqbw6o-rabbitmq-1 rabbitmqctl list_queues name messages consumers
# → должна появиться zorrobpm.jobs.notify.manager с consumers=1 (ваш воркер)
```
Когда пользователь во фронте нажимает «создать заявку»:
1. Фронт → `POST /process-instances` → стартует процесс.
2. Процесс доходит до serviceTask → сообщение в `zorrobpm.jobs.notify.manager`.
3. Ваш воркер забирает → выполняет → `POST /service-tasks/{id}/complete`.
4. Процесс идёт дальше → userTask для менеджера.
5. Фронт (другая view) показывает менеджеру его задачи → менеджер нажимает approve → процесс завершается.
---
## Словарь терминов
| Термин | Значение |
|---|---|
| **BPMN** | Business Process Model and Notation — стандарт описания процессов |
| **Process Definition** | BPMN-схема, загруженная в движок; имеет key + version |
| **Process Instance** | конкретный «запуск» процесса |
| **User Task** | задача живому человеку |
| **Service Task** | задача внешней системе/воркеру (через очередь) |
| **Variables** | данные процесса (ключ-значение с типом) |
| **Gateway** | развилка (XOR/AND) |
| **Incident** | процесс упал, нужен ручной разбор |
| **Job type** | строка (`notify.manager`), определяет очередь в Rabbit |
---
## Частые ошибки
**«CORS error» во фронте при вызове `localhost:9092`.** CORS открыт настежь — проверьте что в DevTools → Network видны response-заголовки `access-control-allow-origin`. Если их нет — скорее всего кеш или перехватчик в браузере.
**Воркер пишет «connection refused: rabbitmq».** Не в той сети. В compose: `networks: [zorrobpm_default]` + `networks: zorrobpm_default: external: true`.
**`service-tasks/{id}/complete` возвращает 404.** Либо `id` уже закрыт, либо вы передаёте `processInstanceId` вместо `taskId`. Получите валидный id из `GET /service-tasks?completed=false`.
**Процесс завис после userTask.** Либо никто не забрал задачу, либо `candidateGroup` не совпадает с тем, кого фронт спрашивает в `GET /user-tasks?candidateGroup=...`.
**После деплоя новой версии BPMN старые инстансы продолжают на старой.** Это норма. Новые `startProcessInstanceByKey` — на последней. Если нужно мигрировать запущенные — через admin UI.
**BPMN деплой 400 «Error parsing BPMN».** XML не экранирован в JSON. Используйте `jq -Rs .` или Swagger UI-форму.
---
## Куда копать дальше
- [ZORRO_SERVER.md](/Users/telecom/Dev/_Zorro/ZORRO_SERVER.md) — архитектура сетей, все паттерны интеграции, живые очереди на стенде
- [zorrobpm-server/API.md](/Users/telecom/Dev/_Zorro/zorrobpm-server/API.md) — полный API reference (все endpoints и схемы)
- [zorrobpm-examples/](/Users/telecom/Dev/_Zorro/zorrobpm-examples/) — 4 готовых BPMN + unit- и E2E-тесты в Maven-проекте
- https://docs.camunda.io/docs/components/modeler/bpmn/bpmn-primer/ — BPMN primer (ZorroBPM синтаксически совместим)
- `http://localhost:9092/swagger-ui/index.html` — live-Swagger с вашими актуальными эндпоинтами

11
leadscore/.env.example Normal file
View File

@ -0,0 +1,11 @@
# Случайный пароль БД (32 символа)
DB_PASSWORD=change-me-to-random-32-chars
# JWT-секрет — случайные 32 байта в base64
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
JWT_SECRET=change-me-to-random-base64
# Логины и bcrypt-хеши паролей
# node -e "console.log(require('bcrypt').hashSync('secret', 10))"
# Формат: username:$2b$10$...,username2:$2b$10$...
AUTH_USERS=manager:$2b$10$REPLACE_WITH_BCRYPT_HASH,rop:$2b$10$REPLACE_WITH_BCRYPT_HASH

7
leadscore/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
.env
.DS_Store
*.log
.vite
coverage

91
leadscore/README.md Normal file
View File

@ -0,0 +1,91 @@
# Lead Scoring App
B2B-лид-скоринг для менеджеров по продажам. Реализация по [PRD.md](../PRD.md) и [TRD.md](../TRD.md), деплой — на Zorro (Dokploy + Traefik).
## Стек
- **Frontend:** Vue 3 + Vite + TypeScript + Pinia + Vue Router + Tailwind CSS, отдаётся nginx.
- **Backend:** Node.js 22 + Fastify + TypeScript, JWT (`@fastify/jwt`) + bcrypt.
- **DB:** PostgreSQL 16, миграции через `node-pg-migrate`.
- **Deploy:** Docker Compose, Traefik-labels для Dokploy.
## Структура
```
leadscore/
├── frontend/ # Vue 3 SPA + nginx
├── backend/ # Node + Fastify + миграции
├── migrations/ # SQL-миграции (копируются в backend-образ)
├── nginx/default.conf # прокси / → frontend, /api/ → backend
├── docker-compose.yaml
└── .env.example
```
## Локальный запуск
1. Скопировать `.env.example` → `.env` и заполнить значения.
```bash
cp .env.example .env
# сгенерировать bcrypt-хеши локально:
cd backend && npm install && node -e "console.log(require('bcrypt').hashSync('secret', 10))"
```
2. Поднять стек:
```bash
docker compose up --build
```
3. Открыть http://localhost (nginx на 80; если 80 занят — добавить `ports: ["8081:80"]` в сервис `nginx` в `docker-compose.yaml`).
### Dev без Docker
```bash
# Backend
cd backend
npm install
DATABASE_URL=postgres://... JWT_SECRET=dev AUTH_USERS='manager:$2b$10$...' npm run dev
# Frontend (в другом терминале)
cd frontend
npm install
npm run dev # http://localhost:3000, проксирует /api → http://localhost:8080
```
## Продакшн-деплой (Dokploy)
1. Запушить репо в git.
2. Dokploy → New Project → `leadscore` → Create Service → Compose → Git URL.
3. Environment → задать `DB_PASSWORD`, `JWT_SECRET`, `AUTH_USERS`.
4. Domains → `leadscore.zorro.kt`.
5. Deploy. Миграции катятся сервисом `migrate` (идемпотентно, завершается после применения).
## API (кратко)
Базовый префикс — `/api`. Все, кроме `/auth/login`, требуют `Authorization: Bearer <jwt>`.
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/auth/login` | `{username, password}` → `{token, user}` |
| GET | `/api/leads` | список (query: `status`, `lead_type`, `sort`, `limit`, `offset`) |
| POST | `/api/leads` | создать — score считается на сервере |
| GET | `/api/leads/:id` | карточка лида + breakdown |
| PATCH| `/api/leads/:id` | редактирование (пересчёт score + history) |
| GET | `/api/leads/:id/history` | история score |
| DELETE | `/api/leads/:id` | удалить (только роль `rop`) |
| GET | `/api/health` | `{ok: true}` |
## Скоринг
Чистая функция в `backend/src/scoring.ts`. Веса и пороги — в TRD §4. Unit-тесты: `backend/test/scoring.test.ts` (`cd backend && npm test`).
## Безопасность
- JWT в `localStorage` (MVP — XSS снимет токен; достаточно для внутреннего инструмента).
- bcrypt cost 10.
- Параметризованные SQL-запросы.
- CORS выключен (бэкенд доступен только через nginx same-origin).
## Тесты
```bash
cd backend && npm test # scoring unit
cd frontend && npm test # компоненты (smoke)
```

View File

@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
test
vitest.config.ts

View File

@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY backend/package.json backend/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY backend/tsconfig.json ./
COPY backend/src ./src
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY backend/package.json backend/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=build /app/dist ./dist
COPY migrations ./migrations
EXPOSE 8080
CMD ["node", "dist/server.js"]

View File

@ -0,0 +1,30 @@
{
"name": "leadscore-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"migrate:up": "node-pg-migrate up -m /app/migrations -j sql",
"migrate:down": "node-pg-migrate down -m /app/migrations -j sql",
"test": "vitest run"
},
"dependencies": {
"@fastify/jwt": "^9.0.1",
"bcrypt": "^5.1.1",
"dotenv": "^16.4.5",
"fastify": "^5.1.0",
"node-pg-migrate": "^7.8.0",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.9.0",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.5"
}
}

View File

@ -0,0 +1,48 @@
import bcrypt from 'bcrypt';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { config } from './config.js';
import { AppError } from './errors.js';
import type { JwtUser } from './types.js';
function parseAuthUsers(raw: string): Map<string, string> {
const map = new Map<string, string>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf(':');
if (idx === -1) continue;
const username = trimmed.slice(0, idx);
const hash = trimmed.slice(idx + 1);
if (username && hash) map.set(username, hash);
}
return map;
}
const users = parseAuthUsers(config.authUsers);
export async function verifyCredentials(
username: string,
password: string,
): Promise<JwtUser | null> {
const hash = users.get(username);
if (!hash) return null;
const ok = await bcrypt.compare(password, hash);
if (!ok) return null;
return { sub: username, role: username };
}
export async function requireAuth(
app: FastifyInstance,
req: FastifyRequest,
): Promise<JwtUser> {
try {
await req.jwtVerify();
} catch {
throw AppError.unauthorized();
}
return req.user as JwtUser;
}
export function requireRole(user: JwtUser, role: string): void {
if (user.role !== role) throw AppError.forbidden(`Requires role ${role}`);
}

View File

@ -0,0 +1,17 @@
import 'dotenv/config';
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
export const config = {
port: Number(process.env.PORT ?? 8080),
host: process.env.HOST ?? '0.0.0.0',
databaseUrl: required('DATABASE_URL'),
jwtSecret: required('JWT_SECRET'),
authUsers: required('AUTH_USERS'),
nodeEnv: process.env.NODE_ENV ?? 'development',
jwtTtl: '12h' as const,
};

View File

@ -0,0 +1,16 @@
import pg from 'pg';
import { config } from './config.js';
pg.types.setTypeParser(
pg.types.builtins.NUMERIC,
(val) => (val === null ? null : Number(val)),
);
export const pool = new pg.Pool({ connectionString: config.databaseUrl });
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
): Promise<pg.QueryResult<T>> {
return pool.query<T>(text, params as never);
}

View File

@ -0,0 +1,32 @@
export type ErrorCode =
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'INTERNAL';
export class AppError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly fields?: Record<string, string>;
constructor(code: ErrorCode, message: string, status: number, fields?: Record<string, string>) {
super(message);
this.code = code;
this.status = status;
this.fields = fields;
}
static validation(message: string, fields?: Record<string, string>) {
return new AppError('VALIDATION_ERROR', message, 400, fields);
}
static unauthorized(message = 'Unauthorized') {
return new AppError('UNAUTHORIZED', message, 401);
}
static forbidden(message = 'Forbidden') {
return new AppError('FORBIDDEN', message, 403);
}
static notFound(message = 'Not found') {
return new AppError('NOT_FOUND', message, 404);
}
}

View File

@ -0,0 +1,25 @@
import type { FastifyInstance } from 'fastify';
import { verifyCredentials } from '../auth.js';
import { AppError } from '../errors.js';
export async function authRoutes(app: FastifyInstance) {
app.post<{ Body: { username?: unknown; password?: unknown } }>(
'/auth/login',
async (req) => {
const { username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string') {
throw AppError.validation('username and password are required');
}
const user = await verifyCredentials(username, password);
if (!user) throw AppError.unauthorized('Invalid credentials');
const token = await app.jwt.sign(
{ sub: user.sub, role: user.role },
{ expiresIn: '12h' },
);
return { token, user: { username: user.sub, role: user.role } };
},
);
}

View File

@ -0,0 +1,5 @@
import type { FastifyInstance } from 'fastify';
export async function healthRoutes(app: FastifyInstance) {
app.get('/health', async () => ({ ok: true }));
}

View File

@ -0,0 +1,443 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { calculateScore } from '../scoring.js';
import { AppError } from '../errors.js';
import { requireAuth } from '../auth.js';
import {
DEAL_STAGES,
type DealStage,
type LeadRow,
type LeadType,
type ScoringInput,
} from '../types.js';
interface LeadBody {
contact_name?: unknown;
contact_phone?: unknown;
email?: unknown;
position?: unknown;
company_name?: unknown;
website?: unknown;
industry?: unknown;
city?: unknown;
lead_type?: unknown;
company_size?: unknown;
budget?: unknown;
urgency?: unknown;
dm_contact?: unknown;
source?: unknown;
prev_contact?: unknown;
deal_stage?: unknown;
expected_amount?: unknown;
next_action_date?: unknown;
comment?: unknown;
}
function requireString(name: string, v: unknown): string {
if (typeof v !== 'string' || !v.trim()) {
throw AppError.validation(`${name} is required`, { [name]: 'required' });
}
return v.trim();
}
function optionalString(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
function parseDealStage(v: unknown, fallback: DealStage = 'new'): DealStage {
if (v === undefined || v === null || v === '') return fallback;
if (typeof v !== 'string' || !(DEAL_STAGES as string[]).includes(v)) {
throw AppError.validation('invalid deal_stage', { deal_stage: 'invalid' });
}
return v as DealStage;
}
function parseAmount(v: unknown): number | null {
if (v === undefined || v === null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n) || n < 0) {
throw AppError.validation('expected_amount must be a non-negative number', {
expected_amount: 'invalid',
});
}
return Math.round(n * 100) / 100;
}
function parseDateOnly(v: unknown): string | null {
if (v === undefined || v === null || v === '') return null;
if (typeof v !== 'string') {
throw AppError.validation('next_action_date must be YYYY-MM-DD', {
next_action_date: 'invalid',
});
}
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!m) {
throw AppError.validation('next_action_date must be YYYY-MM-DD', {
next_action_date: 'invalid',
});
}
return `${m[1]}-${m[2]}-${m[3]}`;
}
function parseScoringFromBody(body: LeadBody): ScoringInput {
const lead_type = body.lead_type;
if (lead_type !== 'inbound' && lead_type !== 'outbound') {
throw AppError.validation('lead_type must be inbound or outbound', {
lead_type: 'invalid',
});
}
const input: ScoringInput = {
lead_type,
company_size: body.company_size as ScoringInput['company_size'],
budget: body.budget as ScoringInput['budget'],
urgency: body.urgency as ScoringInput['urgency'],
dm_contact: body.dm_contact as ScoringInput['dm_contact'],
};
if (lead_type === 'inbound') {
if (body.source !== undefined) input.source = body.source as ScoringInput['source'];
} else {
if (body.prev_contact !== undefined)
input.prev_contact = body.prev_contact as ScoringInput['prev_contact'];
}
return input;
}
function formatDateOnly(d: Date | string | null): string | null {
if (!d) return null;
if (typeof d === 'string') return d.slice(0, 10);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function serializeLead(row: LeadRow, breakdown: Record<string, number>) {
const answers: Record<string, unknown> = {
company_size: row.company_size,
budget: row.budget,
urgency: row.urgency,
dm_contact: row.dm_contact,
};
if (row.source) answers.source = row.source;
if (row.prev_contact) answers.prev_contact = row.prev_contact;
return {
id: row.id,
contact_name: row.contact_name,
contact_phone: row.contact_phone,
email: row.email,
position: row.position,
company_name: row.company_name,
website: row.website,
industry: row.industry,
city: row.city,
lead_type: row.lead_type,
answers,
score: row.score,
status: row.status,
breakdown,
deal_stage: row.deal_stage,
expected_amount: row.expected_amount,
next_action_date: formatDateOnly(row.next_action_date),
comment: row.comment,
created_at: row.created_at,
updated_at: row.updated_at,
created_by: row.created_by,
};
}
async function latestBreakdown(leadId: string): Promise<Record<string, number>> {
const { rows } = await query<{ breakdown: Record<string, number> }>(
`SELECT breakdown FROM score_history WHERE lead_id = $1 ORDER BY changed_at DESC LIMIT 1`,
[leadId],
);
return rows[0]?.breakdown ?? {};
}
export async function leadRoutes(app: FastifyInstance) {
app.addHook('onRequest', async (req) => {
await requireAuth(app, req);
});
// LIST
app.get<{
Querystring: {
status?: string;
lead_type?: string;
deal_stage?: string;
sort?: string;
limit?: string;
offset?: string;
};
}>('/leads', async (req) => {
const { status, lead_type, deal_stage, sort, limit, offset } = req.query;
const where: string[] = [];
const params: unknown[] = [];
if (status && status !== 'all') {
if (!['cold', 'warm', 'hot'].includes(status))
throw AppError.validation('invalid status');
params.push(status);
where.push(`status = $${params.length}`);
}
if (lead_type && lead_type !== 'all') {
if (!['inbound', 'outbound'].includes(lead_type))
throw AppError.validation('invalid lead_type');
params.push(lead_type);
where.push(`lead_type = $${params.length}`);
}
if (deal_stage && deal_stage !== 'all') {
if (!(DEAL_STAGES as string[]).includes(deal_stage))
throw AppError.validation('invalid deal_stage');
params.push(deal_stage);
where.push(`deal_stage = $${params.length}`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const orderSql = sort === 'score_asc' ? 'score ASC' : 'score DESC';
const lim = Math.min(Math.max(Number(limit ?? 50), 1), 500);
const off = Math.max(Number(offset ?? 0), 0);
const dataQ = await query<LeadRow>(
`SELECT * FROM leads ${whereSql} ORDER BY ${orderSql}, created_at DESC LIMIT ${lim} OFFSET ${off}`,
params,
);
const countQ = await query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM leads ${whereSql}`,
params,
);
const data = dataQ.rows.map((row) => ({
id: row.id,
contact_name: row.contact_name,
contact_phone: row.contact_phone,
email: row.email,
position: row.position,
company_name: row.company_name,
website: row.website,
industry: row.industry,
city: row.city,
lead_type: row.lead_type,
score: row.score,
status: row.status,
deal_stage: row.deal_stage,
expected_amount: row.expected_amount,
next_action_date: formatDateOnly(row.next_action_date),
created_at: row.created_at,
updated_at: row.updated_at,
created_by: row.created_by,
}));
return { data, total: Number(countQ.rows[0]?.count ?? 0) };
});
// CREATE
app.post<{ Body: LeadBody }>('/leads', async (req) => {
const user = req.user as { sub: string };
const body = req.body ?? {};
const contact_name = requireString('contact_name', body.contact_name);
const contact_phone = requireString('contact_phone', body.contact_phone);
const company_name = requireString('company_name', body.company_name);
const email = optionalString(body.email);
const position = optionalString(body.position);
const website = optionalString(body.website);
const industry = optionalString(body.industry);
const city = optionalString(body.city);
const comment = optionalString(body.comment);
const deal_stage = parseDealStage(body.deal_stage, 'new');
const expected_amount = parseAmount(body.expected_amount);
const next_action_date = parseDateOnly(body.next_action_date);
const input = parseScoringFromBody(body);
const { total, breakdown, status } = calculateScore(input);
const { rows } = await query<LeadRow>(
`INSERT INTO leads
(contact_name, contact_phone, email, position, company_name, website, industry, city,
lead_type, company_size, budget, urgency, dm_contact, source, prev_contact,
score, status, deal_stage, expected_amount, next_action_date, comment, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
RETURNING *`,
[
contact_name,
contact_phone,
email,
position,
company_name,
website,
industry,
city,
input.lead_type,
input.company_size,
input.budget,
input.urgency,
input.dm_contact,
input.lead_type === 'inbound' ? input.source : null,
input.lead_type === 'outbound' ? input.prev_contact : null,
total,
status,
deal_stage,
expected_amount,
next_action_date,
comment,
user.sub,
],
);
const lead = rows[0]!;
await query(
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`,
[lead.id, total, breakdown, user.sub],
);
return serializeLead(lead, breakdown);
});
// GET BY ID
app.get<{ Params: { id: string } }>('/leads/:id', async (req) => {
const { rows } = await query<LeadRow>(`SELECT * FROM leads WHERE id = $1`, [req.params.id]);
const lead = rows[0];
if (!lead) throw AppError.notFound('Lead not found');
const breakdown = await latestBreakdown(lead.id);
return serializeLead(lead, breakdown);
});
// PATCH
app.patch<{ Params: { id: string }; Body: LeadBody }>('/leads/:id', async (req) => {
const user = req.user as { sub: string };
const { rows: currentRows } = await query<LeadRow>(
`SELECT * FROM leads WHERE id = $1`,
[req.params.id],
);
const current = currentRows[0];
if (!current) throw AppError.notFound('Lead not found');
const body = req.body ?? {};
const contact_name =
typeof body.contact_name === 'string' && body.contact_name.trim()
? body.contact_name.trim()
: current.contact_name;
const contact_phone =
typeof body.contact_phone === 'string' && body.contact_phone.trim()
? body.contact_phone.trim()
: current.contact_phone;
const company_name =
typeof body.company_name === 'string' && body.company_name.trim()
? body.company_name.trim()
: current.company_name;
const email = body.email !== undefined ? optionalString(body.email) : current.email;
const position = body.position !== undefined ? optionalString(body.position) : current.position;
const website = body.website !== undefined ? optionalString(body.website) : current.website;
const industry =
body.industry !== undefined ? optionalString(body.industry) : current.industry;
const city = body.city !== undefined ? optionalString(body.city) : current.city;
const comment = body.comment !== undefined ? optionalString(body.comment) : current.comment;
const deal_stage =
body.deal_stage !== undefined ? parseDealStage(body.deal_stage) : current.deal_stage;
const expected_amount =
body.expected_amount !== undefined
? parseAmount(body.expected_amount)
: current.expected_amount;
const next_action_date =
body.next_action_date !== undefined
? parseDateOnly(body.next_action_date)
: formatDateOnly(current.next_action_date);
const lead_type = (body.lead_type ?? current.lead_type) as LeadType;
const input: ScoringInput = {
lead_type,
company_size: (body.company_size ?? current.company_size) as ScoringInput['company_size'],
budget: (body.budget ?? current.budget) as ScoringInput['budget'],
urgency: (body.urgency ?? current.urgency) as ScoringInput['urgency'],
dm_contact: (body.dm_contact ?? current.dm_contact) as ScoringInput['dm_contact'],
};
if (lead_type === 'inbound') {
const src = body.source ?? current.source;
if (src) input.source = src as ScoringInput['source'];
} else {
const pc = body.prev_contact ?? current.prev_contact;
if (pc) input.prev_contact = pc as ScoringInput['prev_contact'];
}
const { total, breakdown, status } = calculateScore(input);
const { rows } = await query<LeadRow>(
`UPDATE leads SET
contact_name = $1, contact_phone = $2, email = $3, position = $4,
company_name = $5, website = $6, industry = $7, city = $8,
lead_type = $9, company_size = $10, budget = $11, urgency = $12, dm_contact = $13,
source = $14, prev_contact = $15,
score = $16, status = $17,
deal_stage = $18, expected_amount = $19, next_action_date = $20, comment = $21,
updated_at = now()
WHERE id = $22 RETURNING *`,
[
contact_name,
contact_phone,
email,
position,
company_name,
website,
industry,
city,
input.lead_type,
input.company_size,
input.budget,
input.urgency,
input.dm_contact,
input.lead_type === 'inbound' ? input.source : null,
input.lead_type === 'outbound' ? input.prev_contact : null,
total,
status,
deal_stage,
expected_amount,
next_action_date,
comment,
req.params.id,
],
);
const lead = rows[0]!;
if (lead.score !== current.score) {
await query(
`INSERT INTO score_history (lead_id, total, breakdown, changed_by) VALUES ($1,$2,$3,$4)`,
[lead.id, total, breakdown, user.sub],
);
}
return serializeLead(lead, breakdown);
});
// HISTORY
app.get<{ Params: { id: string } }>('/leads/:id/history', async (req) => {
const { rowCount } = await query(`SELECT 1 FROM leads WHERE id = $1`, [req.params.id]);
if (!rowCount) throw AppError.notFound('Lead not found');
const { rows } = await query<{
id: string;
total: number;
breakdown: Record<string, number>;
changed_at: Date;
changed_by: string;
}>(
`SELECT id, total, breakdown, changed_at, changed_by
FROM score_history
WHERE lead_id = $1
ORDER BY changed_at DESC`,
[req.params.id],
);
return { data: rows };
});
// DELETE
app.delete<{ Params: { id: string } }>('/leads/:id', async (req, reply) => {
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
if (!rowCount) throw AppError.notFound('Lead not found');
return reply.code(204).send();
});
}

View File

@ -0,0 +1,71 @@
import type {
Budget,
CompanySize,
DmContact,
LeadStatus,
PrevContact,
ScoringInput,
ScoringResult,
Source,
Urgency,
} from './types.js';
import { AppError } from './errors.js';
const COMPANY_SIZE: Record<CompanySize, number> = { '1-10': 5, '11-50': 15, '50+': 25 };
const BUDGET: Record<Budget, number> = { no: 0, maybe: 12, confirmed: 25 };
const URGENCY: Record<Urgency, number> = { none: 0, '3-6m': 10, 'under-3m': 20 };
const DM_CONTACT: Record<DmContact, number> = { no: 0, influencer: 10, 'decision-maker': 20 };
const SOURCE: Record<Source, number> = { cold: 3, referral: 7, website: 10 };
const PREV_CONTACT: Record<PrevContact, number> = { none: 0, once: 5, 'well-known': 10 };
export function validateScoringInput(input: ScoringInput): void {
const fields: Record<string, string> = {};
if (input.lead_type === 'inbound') {
if (!input.source) fields.source = 'required for inbound';
if (input.prev_contact !== undefined && input.prev_contact !== null)
fields.prev_contact = 'forbidden for inbound';
} else if (input.lead_type === 'outbound') {
if (!input.prev_contact) fields.prev_contact = 'required for outbound';
if (input.source !== undefined && input.source !== null)
fields.source = 'forbidden for outbound';
} else {
fields.lead_type = 'must be inbound or outbound';
}
if (!(input.company_size in COMPANY_SIZE)) fields.company_size = 'invalid';
if (!(input.budget in BUDGET)) fields.budget = 'invalid';
if (!(input.urgency in URGENCY)) fields.urgency = 'invalid';
if (!(input.dm_contact in DM_CONTACT)) fields.dm_contact = 'invalid';
if (Object.keys(fields).length > 0) {
throw AppError.validation('Invalid scoring input', fields);
}
}
function statusFor(total: number): LeadStatus {
if (total <= 40) return 'cold';
if (total <= 70) return 'warm';
return 'hot';
}
export function calculateScore(input: ScoringInput): ScoringResult {
validateScoringInput(input);
const breakdown: Record<string, number> = {
company_size: COMPANY_SIZE[input.company_size],
budget: BUDGET[input.budget],
urgency: URGENCY[input.urgency],
dm_contact: DM_CONTACT[input.dm_contact],
};
if (input.lead_type === 'inbound' && input.source) {
breakdown.source = SOURCE[input.source];
} else if (input.lead_type === 'outbound' && input.prev_contact) {
breakdown.prev_contact = PREV_CONTACT[input.prev_contact];
}
const total = Object.values(breakdown).reduce((a, b) => a + b, 0);
return { total, breakdown, status: statusFor(total) };
}

View File

@ -0,0 +1,53 @@
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import { config } from './config.js';
import { AppError } from './errors.js';
import { authRoutes } from './routes/auth.js';
import { leadRoutes } from './routes/leads.js';
import { healthRoutes } from './routes/health.js';
async function buildApp() {
const app = Fastify({
logger: { level: 'info' },
trustProxy: true,
});
await app.register(jwt, { secret: config.jwtSecret });
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) {
reply.code(err.status).send({
error: { code: err.code, message: err.message, fields: err.fields },
});
return;
}
req.log.error({ err }, 'unhandled error');
reply.code(500).send({
error: { code: 'INTERNAL', message: 'Internal error' },
});
});
app.setNotFoundHandler((_req, reply) => {
reply.code(404).send({
error: { code: 'NOT_FOUND', message: 'Not found' },
});
});
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(leadRoutes);
return app;
}
buildApp()
.then((app) => app.listen({ port: config.port, host: config.host }))
.then((addr) => {
// eslint-disable-next-line no-console
console.log(`leadscore-backend listening on ${addr}`);
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,73 @@
export type LeadType = 'inbound' | 'outbound';
export type CompanySize = '1-10' | '11-50' | '50+';
export type Budget = 'no' | 'maybe' | 'confirmed';
export type Urgency = 'none' | '3-6m' | 'under-3m';
export type DmContact = 'no' | 'influencer' | 'decision-maker';
export type Source = 'cold' | 'referral' | 'website';
export type PrevContact = 'none' | 'once' | 'well-known';
export type LeadStatus = 'cold' | 'warm' | 'hot';
export type DealStage =
| 'new'
| 'contacted'
| 'proposal'
| 'won'
| 'lost';
export const DEAL_STAGES: DealStage[] = [
'new',
'contacted',
'proposal',
'won',
'lost',
];
export interface ScoringInput {
lead_type: LeadType;
company_size: CompanySize;
budget: Budget;
urgency: Urgency;
dm_contact: DmContact;
source?: Source;
prev_contact?: PrevContact;
}
export interface ScoringResult {
total: number;
breakdown: Record<string, number>;
status: LeadStatus;
}
export interface LeadAnswers extends ScoringInput {}
export interface LeadRow {
id: string;
contact_name: string;
contact_phone: string;
email: string;
position: string;
company_name: string;
website: string;
industry: string;
city: string;
lead_type: LeadType;
company_size: CompanySize;
budget: Budget;
urgency: Urgency;
dm_contact: DmContact;
source: Source | null;
prev_contact: PrevContact | null;
score: number;
status: LeadStatus;
deal_stage: DealStage;
expected_amount: number | null;
next_action_date: Date | null;
comment: string;
created_at: Date;
updated_at: Date;
created_by: string;
}
export interface JwtUser {
sub: string;
role: string;
}

View File

@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { calculateScore, validateScoringInput } from '../src/scoring.js';
import type { ScoringInput } from '../src/types.js';
const baseInbound: ScoringInput = {
lead_type: 'inbound',
company_size: '50+',
budget: 'confirmed',
urgency: 'under-3m',
dm_contact: 'decision-maker',
source: 'website',
};
const baseOutbound: ScoringInput = {
lead_type: 'outbound',
company_size: '1-10',
budget: 'no',
urgency: 'none',
dm_contact: 'no',
prev_contact: 'none',
};
describe('calculateScore', () => {
it('max inbound → 100, hot', () => {
const r = calculateScore(baseInbound);
expect(r.total).toBe(100);
expect(r.status).toBe('hot');
expect(r.breakdown).toEqual({
company_size: 25,
budget: 25,
urgency: 20,
dm_contact: 20,
source: 10,
});
});
it('min outbound → 5, cold', () => {
const r = calculateScore(baseOutbound);
expect(r.total).toBe(5);
expect(r.status).toBe('cold');
expect(r.breakdown).toEqual({
company_size: 5,
budget: 0,
urgency: 0,
dm_contact: 0,
prev_contact: 0,
});
expect(Object.values(r.breakdown).reduce((a, b) => a + b, 0)).toBe(r.total);
});
it('warm — 44 is warm', () => {
const r = calculateScore({
lead_type: 'inbound',
company_size: '11-50', // 15
budget: 'maybe', // 12
urgency: '3-6m', // 10
dm_contact: 'no', // 0
source: 'referral', // 7
});
expect(r.total).toBe(44);
expect(r.status).toBe('warm');
});
it('cold boundary — 37 is cold', () => {
const r = calculateScore({
lead_type: 'outbound',
company_size: '11-50', // 15
budget: 'maybe', // 12
urgency: '3-6m', // 10
dm_contact: 'no', // 0
prev_contact: 'none', // 0
});
expect(r.total).toBe(37);
expect(r.status).toBe('cold');
});
it('hot — 80 is hot', () => {
const r = calculateScore({
lead_type: 'outbound',
company_size: '50+', // 25
budget: 'confirmed', // 25
urgency: '3-6m', // 10
dm_contact: 'influencer', // 10
prev_contact: 'well-known', // 10
});
expect(r.total).toBe(80);
expect(r.status).toBe('hot');
});
it('budget=no no longer caps the score', () => {
const r = calculateScore({
lead_type: 'inbound',
company_size: '50+', // 25
budget: 'no', // 0
urgency: 'under-3m', // 20
dm_contact: 'decision-maker', // 20
source: 'website', // 10
});
expect(r.total).toBe(75);
expect(r.status).toBe('hot');
});
});
describe('validateScoringInput', () => {
it('inbound without source → throws', () => {
expect(() =>
validateScoringInput({ ...baseInbound, source: undefined }),
).toThrowError(/Invalid scoring input/);
});
it('inbound with prev_contact → throws', () => {
expect(() =>
validateScoringInput({ ...baseInbound, prev_contact: 'once' as never }),
).toThrowError(/Invalid scoring input/);
});
it('outbound without prev_contact → throws', () => {
expect(() =>
validateScoringInput({ ...baseOutbound, prev_contact: undefined }),
).toThrowError(/Invalid scoring input/);
});
it('outbound with source → throws', () => {
expect(() =>
validateScoringInput({ ...baseOutbound, source: 'cold' as never }),
).toThrowError(/Invalid scoring input/);
});
});

View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@ -0,0 +1,86 @@
services:
frontend:
build:
context: ./frontend
networks:
internal:
aliases: [leadscore-fe]
restart: unless-stopped
backend:
build:
context: .
dockerfile: ./backend/Dockerfile
networks:
internal:
aliases: [leadscore-be]
environment:
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
JWT_SECRET: ${JWT_SECRET}
AUTH_USERS: ${AUTH_USERS}
NODE_ENV: production
PORT: "8080"
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
postgres:
image: postgres:16-alpine
networks: [internal]
environment:
POSTGRES_USER: leadscore
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: leadscore
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U leadscore"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
migrate:
build:
context: .
dockerfile: ./backend/Dockerfile
command: npm run migrate:up
networks: [internal]
environment:
DATABASE_URL: postgres://leadscore:${DB_PASSWORD}@postgres:5432/leadscore
depends_on:
postgres:
condition: service_healthy
restart: "no"
nginx:
build:
context: ./nginx
networks: [internal, dokploy-network]
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
- traefik.http.routers.leadscore-http.entrypoints=web
- traefik.http.routers.leadscore-http.priority=20
- traefik.http.routers.leadscore-http.rule=HostRegexp(`leadscore.+`)
- traefik.http.routers.leadscore-http.service=leadscore
- traefik.http.routers.leadscore-https.entrypoints=websecure
- traefik.http.routers.leadscore-https.priority=20
- traefik.http.routers.leadscore-https.rule=HostRegexp(`leadscore.+`)
- traefik.http.routers.leadscore-https.service=leadscore
- traefik.http.routers.leadscore-https.tls=true
- traefik.http.services.leadscore.loadbalancer.server.port=80
depends_on: [frontend, backend]
restart: unless-stopped
volumes:
pgdata:
networks:
internal:
dokploy-network:
external: true

View File

@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

View File

@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY . .
ARG VITE_BASE=/
ARG VITE_API_BASE=/api
ENV VITE_BASE=${VITE_BASE}
ENV VITE_API_BASE=${VITE_API_BASE}
RUN npm run build
FROM nginx:alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

15
leadscore/frontend/env.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lead Scoring</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|svg|woff2?|ttf|eot|png|jpg|jpeg|gif|webp|ico)$ {
expires 7d;
access_log off;
try_files $uri =404;
}
}

View File

@ -0,0 +1,30 @@
{
"name": "leadscore-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@vitejs/plugin-vue": "^5.2.0",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.5",
"vue-tsc": "^2.1.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const router = useRouter();
function logout() {
auth.logout();
router.push({ name: 'login' });
}
</script>
<template>
<div class="min-h-full bg-gray-50 text-gray-900">
<header
v-if="auth.isAuthenticated"
class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between"
>
<div class="flex items-center gap-6">
<RouterLink :to="{ name: 'leads' }" class="font-bold text-lg">
Lead Scoring
</RouterLink>
<RouterLink
:to="{ name: 'leads' }"
class="text-sm text-gray-600 hover:text-gray-900"
>
Лиды
</RouterLink>
<RouterLink
:to="{ name: 'new-lead' }"
class="text-sm text-gray-600 hover:text-gray-900"
>
+ Новый лид
</RouterLink>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-gray-500">
{{ auth.user?.username }} ({{ auth.user?.role }})
</span>
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
@click="logout"
>
Выйти
</button>
</div>
</header>
<main class="p-6">
<RouterView />
</main>
</div>
</template>

View File

@ -0,0 +1,13 @@
import { apiFetch } from './client';
export interface LoginResponse {
token: string;
user: { username: string; role: string };
}
export function login(username: string, password: string) {
return apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
}

View File

@ -0,0 +1,59 @@
const BASE = import.meta.env.VITE_API_BASE || '/api';
export interface ApiError {
code: string;
message: string;
fields?: Record<string, string>;
}
export class ApiRequestError extends Error {
status: number;
code: string;
fields?: Record<string, string>;
constructor(status: number, err: ApiError) {
super(err.message);
this.status = status;
this.code = err.code;
this.fields = err.fields;
}
}
let unauthorizedHandler: () => void = () => {};
export function setUnauthorizedHandler(fn: () => void) {
unauthorizedHandler = fn;
}
function getToken(): string | null {
return localStorage.getItem('token');
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = new Headers(options.headers);
if (!headers.has('Content-Type') && options.body) {
headers.set('Content-Type', 'application/json');
}
const token = getToken();
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 204) return undefined as unknown as T;
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) {
const err: ApiError = body?.error ?? {
code: 'INTERNAL',
message: 'Unexpected error',
};
if (res.status === 401) unauthorizedHandler();
throw new ApiRequestError(res.status, err);
}
return body as T;
}

View File

@ -0,0 +1,164 @@
import { apiFetch } from './client';
export type LeadType = 'inbound' | 'outbound';
export type LeadStatus = 'cold' | 'warm' | 'hot';
export type CompanySize = '1-10' | '11-50' | '50+';
export type Budget = 'no' | 'maybe' | 'confirmed';
export type Urgency = 'none' | '3-6m' | 'under-3m';
export type DmContact = 'no' | 'influencer' | 'decision-maker';
export type Source = 'cold' | 'referral' | 'website';
export type PrevContact = 'none' | 'once' | 'well-known';
export type DealStage =
| 'new'
| 'contacted'
| 'proposal'
| 'won'
| 'lost';
export const DEAL_STAGES: DealStage[] = [
'new',
'contacted',
'proposal',
'won',
'lost',
];
export const DEAL_STAGE_LABELS: Record<DealStage, string> = {
new: 'Новый',
contacted: 'Связались',
proposal: 'КП отправлено',
won: 'Выиграна',
lost: 'Проиграна',
};
export interface LeadListItem {
id: string;
contact_name: string;
contact_phone: string;
email: string;
position: string;
company_name: string;
website: string;
industry: string;
city: string;
lead_type: LeadType;
score: number;
status: LeadStatus;
deal_stage: DealStage;
expected_amount: number | null;
next_action_date: string | null;
created_at: string;
updated_at: string;
created_by: string;
}
export interface Lead {
id: string;
contact_name: string;
contact_phone: string;
email: string;
position: string;
company_name: string;
website: string;
industry: string;
city: string;
lead_type: LeadType;
answers: {
company_size: CompanySize;
budget: Budget;
urgency: Urgency;
dm_contact: DmContact;
source?: Source;
prev_contact?: PrevContact;
};
score: number;
status: LeadStatus;
breakdown: Record<string, number>;
deal_stage: DealStage;
expected_amount: number | null;
next_action_date: string | null;
comment: string;
created_at: string;
updated_at: string;
created_by: string;
}
export interface HistoryEntry {
id: string;
total: number;
breakdown: Record<string, number>;
changed_at: string;
changed_by: string;
}
export interface LeadListQuery {
status?: LeadStatus | 'all';
lead_type?: LeadType | 'all';
deal_stage?: DealStage | 'all';
sort?: 'score_desc' | 'score_asc';
limit?: number;
offset?: number;
}
export interface CreateLeadBody {
contact_name: string;
contact_phone: string;
email?: string;
position?: string;
company_name: string;
website?: string;
industry?: string;
city?: string;
lead_type: LeadType;
company_size: CompanySize;
budget: Budget;
urgency: Urgency;
dm_contact: DmContact;
source?: Source;
prev_contact?: PrevContact;
deal_stage?: DealStage;
expected_amount?: number | null;
next_action_date?: string | null;
comment?: string;
}
export type UpdateLeadBody = Partial<CreateLeadBody>;
function qs(params: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
export function listLeads(q: LeadListQuery = {}) {
return apiFetch<{ data: LeadListItem[]; total: number }>(`/leads${qs(q as Record<string, unknown>)}`);
}
export function getLead(id: string) {
return apiFetch<Lead>(`/leads/${id}`);
}
export function createLead(body: CreateLeadBody) {
return apiFetch<Lead>('/leads', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function updateLead(id: string, body: UpdateLeadBody) {
return apiFetch<Lead>(`/leads/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export function deleteLead(id: string) {
return apiFetch<void>(`/leads/${id}`, { method: 'DELETE' });
}
export function getLeadHistory(id: string) {
return apiFetch<{ data: HistoryEntry[] }>(`/leads/${id}/history`);
}

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { useLeadsStore } from '@/stores/leads';
import {
DEAL_STAGES,
DEAL_STAGE_LABELS,
type DealStage,
type LeadStatus,
type LeadType,
} from '@/api/leads';
const store = useLeadsStore();
const statuses: { value: LeadStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'hot', label: '🟢 Горячие' },
{ value: 'warm', label: '🟡 Тёплые' },
{ value: 'cold', label: '🔴 Холодные' },
];
const types: { value: LeadType | 'all'; label: string }[] = [
{ value: 'all', label: 'Все типы' },
{ value: 'inbound', label: 'Inbound' },
{ value: 'outbound', label: 'Outbound' },
];
const stages: { value: DealStage | 'all'; label: string }[] = [
{ value: 'all', label: 'Все этапы' },
...DEAL_STAGES.map((s) => ({ value: s, label: DEAL_STAGE_LABELS[s] })),
];
</script>
<template>
<div class="flex flex-wrap gap-4 items-center">
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-500">Статус:</span>
<button
v-for="s in statuses"
:key="s.value"
type="button"
class="px-3 py-1 rounded-full border text-sm transition"
:class="
store.filters.status === s.value
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
"
@click="store.setStatus(s.value)"
>
{{ s.label }}
</button>
</div>
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-500">Тип:</span>
<button
v-for="t in types"
:key="t.value"
type="button"
class="px-3 py-1 rounded-full border text-sm transition"
:class="
store.filters.lead_type === t.value
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
"
@click="store.setLeadType(t.value)"
>
{{ t.label }}
</button>
</div>
<div class="flex gap-2 items-center">
<span class="text-sm text-gray-500">Этап:</span>
<button
v-for="s in stages"
:key="s.value"
type="button"
class="px-3 py-1 rounded-full border text-sm transition"
:class="
store.filters.deal_stage === s.value
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
"
@click="store.setDealStage(s.value)"
>
{{ s.label }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts" generic="T extends string | number | boolean">
interface Option {
value: T;
label: string;
}
const props = defineProps<{
label: string;
modelValue: T | null | undefined;
options: Option[];
}>();
const emit = defineEmits<{ (e: 'update:modelValue', v: T): void }>();
function select(v: T) {
emit('update:modelValue', v);
}
</script>
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">{{ label }}</label>
<div class="flex flex-wrap gap-2">
<button
v-for="o in options"
:key="String(o.value)"
type="button"
class="px-4 py-2 rounded-lg border text-sm transition"
:class="
props.modelValue === o.value
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
"
@click="select(o.value)"
>
{{ o.label }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { LeadStatus } from '@/api/leads';
const props = defineProps<{ score: number; status: LeadStatus }>();
const styles = computed(() => {
switch (props.status) {
case 'cold':
return 'bg-red-100 text-red-700 border-red-300';
case 'warm':
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
case 'hot':
return 'bg-green-100 text-green-700 border-green-300';
}
});
const icon = computed(() => {
switch (props.status) {
case 'cold':
return '🔴';
case 'warm':
return '🟡';
case 'hot':
return '🟢';
}
});
</script>
<template>
<span
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-sm font-semibold border"
:class="styles"
>
<span>{{ icon }}</span>
<span>{{ score }}</span>
</span>
</template>

View File

@ -0,0 +1,19 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setUnauthorizedHandler } from './api/client';
import { useAuthStore } from './stores/auth';
import './style.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
const auth = useAuthStore();
setUnauthorizedHandler(() => {
auth.logout();
router.push({ name: 'login' });
});
app.mount('#app');

View File

@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
name: 'leads',
component: () => import('@/views/LeadsListView.vue'),
},
{
path: '/leads/new',
name: 'new-lead',
component: () => import('@/views/NewLeadView.vue'),
},
{
path: '/leads/:id',
name: 'lead-card',
component: () => import('@/views/LeadCardView.vue'),
props: true,
},
],
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (!to.meta.public && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'leads' };
}
});
export default router;

View File

@ -0,0 +1,45 @@
import { defineStore } from 'pinia';
import { login as apiLogin } from '@/api/auth';
interface User {
username: string;
role: string;
}
interface State {
token: string | null;
user: User | null;
}
export const useAuthStore = defineStore('auth', {
state: (): State => ({
token: localStorage.getItem('token'),
user: (() => {
const raw = localStorage.getItem('user');
try {
return raw ? (JSON.parse(raw) as User) : null;
} catch {
return null;
}
})(),
}),
getters: {
isAuthenticated: (s) => !!s.token,
isRop: (s) => s.user?.role === 'rop',
},
actions: {
async login(username: string, password: string) {
const res = await apiLogin(username, password);
this.token = res.token;
this.user = res.user;
localStorage.setItem('token', res.token);
localStorage.setItem('user', JSON.stringify(res.user));
},
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
},
},
});

View File

@ -0,0 +1,63 @@
import { defineStore } from 'pinia';
import {
listLeads,
type DealStage,
type LeadListItem,
type LeadStatus,
type LeadType,
} from '@/api/leads';
interface State {
items: LeadListItem[];
total: number;
loading: boolean;
error: string | null;
filters: {
status: LeadStatus | 'all';
lead_type: LeadType | 'all';
deal_stage: DealStage | 'all';
};
}
export const useLeadsStore = defineStore('leads', {
state: (): State => ({
items: [],
total: 0,
loading: false,
error: null,
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
}),
actions: {
async load() {
this.loading = true;
this.error = null;
try {
const res = await listLeads({
status: this.filters.status,
lead_type: this.filters.lead_type,
deal_stage: this.filters.deal_stage,
sort: 'score_desc',
limit: 100,
});
this.items = res.data;
this.total = res.total;
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
},
setStatus(status: LeadStatus | 'all') {
this.filters.status = status;
return this.load();
},
setLeadType(lead_type: LeadType | 'all') {
this.filters.lead_type = lead_type;
return this.load();
},
setDealStage(deal_stage: DealStage | 'all') {
this.filters.deal_stage = deal_stage;
return this.load();
},
},
});

View File

@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #app {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

View File

@ -0,0 +1,357 @@
<script setup lang="ts">
import { onMounted, ref, computed, reactive, watch } from 'vue';
import { useRouter } from 'vue-router';
import ScoreBadge from '@/components/ScoreBadge.vue';
import {
deleteLead,
getLead,
getLeadHistory,
updateLead,
DEAL_STAGES,
DEAL_STAGE_LABELS,
type DealStage,
type HistoryEntry,
type Lead,
} from '@/api/leads';
import { ApiRequestError } from '@/api/client';
const props = defineProps<{ id: string }>();
const lead = ref<Lead | null>(null);
const history = ref<HistoryEntry[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const pipelineForm = reactive({
deal_stage: 'new' as DealStage,
expected_amount: '' as string,
next_action_date: '' as string,
comment: '' as string,
});
const saving = ref(false);
const saveMsg = ref<string | null>(null);
const router = useRouter();
const recommendation = computed(() => {
if (!lead.value) return '';
switch (lead.value.status) {
case 'cold':
return 'Не трать время сейчас';
case 'warm':
return 'Nurture, следи за активностью';
case 'hot':
return 'Звони сегодня';
}
});
const stageBadgeClass = computed(() => {
if (!lead.value) return '';
const map: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800',
proposal: 'bg-amber-100 text-amber-800',
won: 'bg-green-100 text-green-800',
lost: 'bg-red-100 text-red-800',
};
return map[lead.value.deal_stage];
});
const breakdownLabels: Record<string, string> = {
company_size: 'Размер компании',
budget: 'Бюджет',
urgency: 'Срочность',
dm_contact: 'Контакт — ЛПР',
source: 'Источник',
prev_contact: 'Предыдущий контакт',
};
const breakdownMax: Record<string, number> = {
company_size: 25,
budget: 25,
urgency: 20,
dm_contact: 20,
source: 10,
prev_contact: 10,
};
async function load() {
loading.value = true;
try {
const [leadData, historyData] = await Promise.all([
getLead(props.id),
getLeadHistory(props.id),
]);
lead.value = leadData;
history.value = historyData.data;
pipelineForm.deal_stage = leadData.deal_stage;
pipelineForm.expected_amount =
leadData.expected_amount !== null ? String(leadData.expected_amount) : '';
pipelineForm.next_action_date = leadData.next_action_date ?? '';
pipelineForm.comment = leadData.comment ?? '';
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
}
function parseAmountForApi(raw: unknown): number | null {
if (raw === '' || raw === null || raw === undefined) return null;
const s = typeof raw === 'string' ? raw.trim().replace(',', '.') : String(raw);
if (s === '') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
async function savePipeline() {
if (!lead.value) return;
saveMsg.value = null;
saving.value = true;
try {
const updated = await updateLead(lead.value.id, {
deal_stage: pipelineForm.deal_stage,
expected_amount: parseAmountForApi(pipelineForm.expected_amount),
next_action_date: pipelineForm.next_action_date === '' ? null : pipelineForm.next_action_date,
comment: pipelineForm.comment,
});
lead.value = updated;
pipelineForm.expected_amount =
updated.expected_amount !== null ? String(updated.expected_amount) : '';
saveMsg.value = 'Сохранено';
} catch (e) {
saveMsg.value = e instanceof ApiRequestError ? e.message : 'Ошибка сохранения';
} finally {
saving.value = false;
}
}
watch(
() => [
pipelineForm.deal_stage,
pipelineForm.expected_amount,
pipelineForm.next_action_date,
pipelineForm.comment,
],
() => {
saveMsg.value = null;
},
);
async function remove() {
if (!confirm('Удалить лида?')) return;
try {
await deleteLead(props.id);
router.push({ name: 'leads' });
} catch (e) {
error.value = (e as Error).message;
}
}
function formatDateTime(s: string) {
return new Date(s).toLocaleString('ru-RU');
}
function formatDate(s: string | null) {
if (!s) return '—';
return new Date(s + 'T00:00:00').toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatMoney(v: number | null) {
if (v === null) return '—';
return v.toLocaleString('ru-RU', { maximumFractionDigits: 2 }) + ' ₸';
}
function websiteHref(raw: string) {
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
}
onMounted(load);
</script>
<template>
<div class="max-w-3xl mx-auto space-y-6">
<button
type="button"
class="text-sm text-gray-600 hover:text-gray-900"
@click="$router.push({ name: 'leads' })"
>
← К списку
</button>
<div v-if="loading" class="text-gray-500">Загрузка…</div>
<div v-else-if="error" class="text-red-600">{{ error }}</div>
<template v-else-if="lead">
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-2xl font-bold truncate">{{ lead.contact_name }}</h1>
<p v-if="lead.position" class="text-sm text-gray-600">{{ lead.position }}</p>
<p class="text-gray-600 mt-1">{{ lead.company_name }}</p>
<div class="mt-2 space-y-1 text-sm">
<p v-if="lead.contact_phone">
📞 <a :href="`tel:${lead.contact_phone}`" class="hover:underline">{{ lead.contact_phone }}</a>
</p>
<p v-if="lead.email">
✉️ <a :href="`mailto:${lead.email}`" class="hover:underline">{{ lead.email }}</a>
</p>
<p v-if="lead.website">
🌐 <a :href="websiteHref(lead.website)" target="_blank" rel="noopener" class="hover:underline">
{{ lead.website }}
</a>
</p>
<p v-if="lead.industry || lead.city" class="text-gray-500">
<span v-if="lead.industry">{{ lead.industry }}</span>
<span v-if="lead.industry && lead.city"> · </span>
<span v-if="lead.city">{{ lead.city }}</span>
</p>
</div>
<p class="text-xs text-gray-500 mt-3">
{{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }} · создан
{{ lead.created_by }} · {{ formatDateTime(lead.created_at) }}
</p>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ScoreBadge :score="lead.score" :status="lead.status" />
<span
class="text-xs font-medium px-2 py-1 rounded-full"
:class="stageBadgeClass"
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
</div>
</div>
<div
class="rounded-lg p-4"
:class="{
'bg-red-50 text-red-900': lead.status === 'cold',
'bg-yellow-50 text-yellow-900': lead.status === 'warm',
'bg-green-50 text-green-900': lead.status === 'hot',
}"
>
<p class="font-medium">Рекомендация</p>
<p>{{ recommendation }}</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h2 class="font-semibold">Pipeline</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Этап воронки</label>
<select
v-model="pipelineForm.deal_stage"
class="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
>
<option v-for="s in DEAL_STAGES" :key="s" :value="s">
{{ DEAL_STAGE_LABELS[s] }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
<input
v-model="pipelineForm.expected_amount"
type="number"
min="0"
step="0.01"
placeholder="500000"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Следующее касание</label>
<input
v-model="pipelineForm.next_action_date"
type="date"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1">Комментарий</label>
<textarea
v-model="pipelineForm.comment"
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 disabled:opacity-50"
:disabled="saving"
@click="savePipeline"
>
{{ saving ? 'Сохраняем…' : 'Сохранить' }}
</button>
<span v-if="saveMsg" class="text-sm text-gray-600">{{ saveMsg }}</span>
</div>
<div class="pt-2 text-sm text-gray-600 space-y-1 border-t border-gray-100">
<p>
<span class="text-gray-500">Сумма:</span> {{ formatMoney(lead.expected_amount) }}
</p>
<p>
<span class="text-gray-500">Следующее касание:</span> {{ formatDate(lead.next_action_date) }}
</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
<h2 class="font-semibold">Детализация баллов</h2>
<ul class="divide-y divide-gray-100">
<li
v-for="(pts, key) in lead.breakdown"
:key="key"
class="flex justify-between py-2 text-sm"
>
<span class="text-gray-600">{{ breakdownLabels[key] ?? key }}</span>
<span v-if="pts < 0" class="font-medium text-red-600">{{ pts }}</span>
<span v-else class="font-medium">
{{ pts }}<span class="text-gray-400">/{{ breakdownMax[key] ?? pts }}</span>
</span>
</li>
</ul>
<div class="flex justify-between pt-2 border-t border-gray-200 font-semibold">
<span>Итого</span>
<span>{{ lead.score }} / 100</span>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
<h2 class="font-semibold">История изменений score</h2>
<div v-if="history.length === 0" class="text-sm text-gray-500">
История пуста
</div>
<ul v-else class="divide-y divide-gray-100">
<li
v-for="entry in history"
:key="entry.id"
class="py-2 text-sm flex justify-between"
>
<span class="text-gray-600">
{{ formatDateTime(entry.changed_at) }} · {{ entry.changed_by }}
</span>
<span class="font-medium">{{ entry.total }}</span>
</li>
</ul>
</div>
<div class="flex gap-3">
<button
type="button"
class="text-red-600 hover:text-red-800 text-sm"
@click="remove"
>
Удалить лида
</button>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { RouterLink } from 'vue-router';
import { useLeadsStore } from '@/stores/leads';
import ScoreBadge from '@/components/ScoreBadge.vue';
import FilterBar from '@/components/FilterBar.vue';
import { DEAL_STAGE_LABELS, deleteLead, type DealStage } from '@/api/leads';
const store = useLeadsStore();
onMounted(() => store.load());
function formatDate(s: string | null) {
if (!s) return '—';
return new Date(s.length === 10 ? s + 'T00:00:00' : s).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function formatMoney(v: number | null) {
if (v === null) return '—';
return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' ₸';
}
const stageClass: Record<DealStage, string> = {
new: 'bg-gray-100 text-gray-700',
contacted: 'bg-blue-100 text-blue-800',
proposal: 'bg-amber-100 text-amber-800',
won: 'bg-green-100 text-green-800',
lost: 'bg-red-100 text-red-800',
};
async function removeLead(id: string, name: string) {
if (!confirm(`Удалить лида «${name}»?`)) return;
try {
await deleteLead(id);
await store.load();
} catch (e) {
alert((e as Error).message);
}
}
</script>
<template>
<div class="max-w-6xl mx-auto space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Лиды ({{ store.total }})</h1>
<RouterLink
:to="{ name: 'new-lead' }"
class="bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800"
>
+ Добавить лида
</RouterLink>
</div>
<FilterBar />
<div v-if="store.loading" class="text-gray-500">Загрузка…</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div
v-else-if="store.items.length === 0"
class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-500"
>
Лидов пока нет. Добавь первого.
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase">
<tr>
<th class="text-left px-4 py-3 font-medium">Score</th>
<th class="text-left px-4 py-3 font-medium">Контакт</th>
<th class="text-left px-4 py-3 font-medium">Компания</th>
<th class="text-left px-4 py-3 font-medium">Этап</th>
<th class="text-left px-4 py-3 font-medium">Сумма</th>
<th class="text-left px-4 py-3 font-medium">След. касание</th>
<th class="text-left px-4 py-3 font-medium">Тип</th>
<th class="text-left px-4 py-3 font-medium">Автор</th>
<th class="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
<tr
v-for="lead in store.items"
:key="lead.id"
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
@click="$router.push({ name: 'lead-card', params: { id: lead.id } })"
>
<td class="px-4 py-3">
<ScoreBadge :score="lead.score" :status="lead.status" />
</td>
<td class="px-4 py-3">
<div>{{ lead.contact_name }}</div>
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
</td>
<td class="px-4 py-3">
<div>{{ lead.company_name }}</div>
<div v-if="lead.city" class="text-xs text-gray-500">{{ lead.city }}</div>
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-1 rounded-full"
:class="stageClass[lead.deal_stage]"
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
</td>
<td class="px-4 py-3 text-gray-700">{{ formatMoney(lead.expected_amount) }}</td>
<td class="px-4 py-3 text-gray-600">{{ formatDate(lead.next_action_date) }}</td>
<td class="px-4 py-3 text-gray-600">
{{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }}
</td>
<td class="px-4 py-3 text-gray-600">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right">
<button
type="button"
class="text-red-600 hover:text-red-800 text-sm"
title="Удалить"
@click.stop="removeLead(lead.id, lead.contact_name)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { ApiRequestError } from '@/api/client';
const username = ref('');
const password = ref('');
const error = ref<string | null>(null);
const loading = ref(false);
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
async function submit() {
error.value = null;
loading.value = true;
try {
await auth.login(username.value, password.value);
const redirect = (route.query.redirect as string) || '/';
router.push(redirect);
} catch (e) {
if (e instanceof ApiRequestError) {
error.value = e.status === 401 ? 'Неверный логин или пароль' : e.message;
} else {
error.value = 'Ошибка соединения';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-sm mx-auto mt-24 bg-white rounded-xl shadow p-8">
<h1 class="text-2xl font-bold mb-6">Вход</h1>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="block text-sm font-medium mb-1">Логин</label>
<input
v-model="username"
type="text"
autocomplete="username"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Пароль</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-900"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full bg-gray-900 text-white rounded-lg py-2 font-medium hover:bg-gray-800 disabled:opacity-50"
>
{{ loading ? 'Вход…' : 'Войти' }}
</button>
</form>
</div>
</template>

View File

@ -0,0 +1,346 @@
<script setup lang="ts">
import { reactive, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import QuestionButton from '@/components/QuestionButton.vue';
import {
createLead,
DEAL_STAGES,
DEAL_STAGE_LABELS,
type Budget,
type CompanySize,
type CreateLeadBody,
type DealStage,
type DmContact,
type LeadType,
type PrevContact,
type Source,
type Urgency,
} from '@/api/leads';
import { ApiRequestError } from '@/api/client';
interface FormState {
contact_name: string;
contact_phone: string;
email: string;
position: string;
company_name: string;
website: string;
industry: string;
city: string;
lead_type: LeadType;
company_size: CompanySize | null;
budget: Budget | null;
urgency: Urgency | null;
dm_contact: DmContact | null;
source: Source | null;
prev_contact: PrevContact | null;
deal_stage: DealStage;
expected_amount: string;
next_action_date: string;
comment: string;
}
const form = reactive<FormState>({
contact_name: '',
contact_phone: '',
email: '',
position: '',
company_name: '',
website: '',
industry: '',
city: '',
lead_type: 'inbound',
company_size: null,
budget: null,
urgency: null,
dm_contact: null,
source: null,
prev_contact: null,
deal_stage: 'new',
expected_amount: '',
next_action_date: '',
comment: '',
});
const error = ref<string | null>(null);
const fieldErrors = ref<Record<string, string>>({});
const loading = ref(false);
const router = useRouter();
const leadTypeOpts: { value: LeadType; label: string }[] = [
{ value: 'inbound', label: 'Inbound' },
{ value: 'outbound', label: 'Outbound' },
];
const companySizeOpts: { value: CompanySize; label: string }[] = [
{ value: '1-10', label: '1–10' },
{ value: '11-50', label: '11–50' },
{ value: '50+', label: '50+' },
];
const budgetOpts: { value: Budget; label: string }[] = [
{ value: 'no', label: 'Нет' },
{ value: 'maybe', label: 'Возможно' },
{ value: 'confirmed', label: 'Подтверждён' },
];
const urgencyOpts: { value: Urgency; label: string }[] = [
{ value: 'none', label: 'Нет дедлайна' },
{ value: '3-6m', label: '3–6 месяцев' },
{ value: 'under-3m', label: 'До 3 месяцев' },
];
const dmContactOpts: { value: DmContact; label: string }[] = [
{ value: 'no', label: 'Нет' },
{ value: 'influencer', label: 'Влияет на решение' },
{ value: 'decision-maker', label: 'Принимает решение' },
];
const sourceOpts: { value: Source; label: string }[] = [
{ value: 'cold', label: 'Холодный' },
{ value: 'referral', label: 'Реферал' },
{ value: 'website', label: 'Сайт / входящий' },
];
const prevContactOpts: { value: PrevContact; label: string }[] = [
{ value: 'none', label: 'Не было' },
{ value: 'once', label: 'Встречались один раз' },
{ value: 'well-known', label: 'Знакомы хорошо' },
];
const canSubmit = computed(() => {
if (!form.contact_name.trim() || !form.contact_phone.trim() || !form.company_name.trim()) return false;
if (!form.company_size || !form.budget || !form.urgency || !form.dm_contact) return false;
if (form.lead_type === 'inbound' && !form.source) return false;
if (form.lead_type === 'outbound' && !form.prev_contact) return false;
return true;
});
async function submit() {
error.value = null;
fieldErrors.value = {};
loading.value = true;
try {
const body: CreateLeadBody = {
contact_name: form.contact_name.trim(),
contact_phone: form.contact_phone.trim(),
company_name: form.company_name.trim(),
lead_type: form.lead_type,
company_size: form.company_size!,
budget: form.budget!,
urgency: form.urgency!,
dm_contact: form.dm_contact!,
deal_stage: form.deal_stage,
};
if (form.email.trim()) body.email = form.email.trim();
if (form.position.trim()) body.position = form.position.trim();
if (form.website.trim()) body.website = form.website.trim();
if (form.industry.trim()) body.industry = form.industry.trim();
if (form.city.trim()) body.city = form.city.trim();
if (form.comment.trim()) body.comment = form.comment.trim();
if (form.next_action_date) body.next_action_date = form.next_action_date;
const rawAmount = form.expected_amount as unknown;
if (rawAmount !== '' && rawAmount !== null && rawAmount !== undefined) {
const s = typeof rawAmount === 'string' ? rawAmount.trim().replace(',', '.') : String(rawAmount);
const n = Number(s);
if (Number.isFinite(n)) body.expected_amount = n;
}
if (form.lead_type === 'inbound') body.source = form.source!;
else body.prev_contact = form.prev_contact!;
const lead = await createLead(body);
router.push({ name: 'lead-card', params: { id: lead.id } });
} catch (e) {
if (e instanceof ApiRequestError) {
error.value = e.message;
if (e.fields) fieldErrors.value = e.fields;
} else {
error.value = 'Ошибка соединения';
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Новый лид</h1>
<form class="bg-white rounded-xl border border-gray-200 p-6 space-y-6" @submit.prevent="submit">
<section class="space-y-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Контакт</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Имя контакта</label>
<input
v-model="form.contact_name"
type="text"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Телефон</label>
<input
v-model="form.contact_phone"
type="tel"
required
placeholder="+7 ..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<p v-if="fieldErrors.contact_phone" class="mt-1 text-xs text-red-600">
{{ fieldErrors.contact_phone }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Email</label>
<input
v-model="form.email"
type="email"
placeholder="ivan@acme.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Должность</label>
<input
v-model="form.position"
type="text"
placeholder="CEO / Head of Sales / …"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
</section>
<section class="space-y-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Компания</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1">Название компании</label>
<input
v-model="form.company_name"
type="text"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Сайт</label>
<input
v-model="form.website"
type="url"
placeholder="https://acme.com"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Отрасль</label>
<input
v-model="form.industry"
type="text"
placeholder="SaaS / Retail / …"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Город</label>
<input
v-model="form.city"
type="text"
placeholder="Алматы"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
</section>
<section class="space-y-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Скоринг</h2>
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
<QuestionButton label="Размер компании" v-model="form.company_size" :options="companySizeOpts" />
<QuestionButton label="Бюджет" v-model="form.budget" :options="budgetOpts" />
<QuestionButton label="Срочность" v-model="form.urgency" :options="urgencyOpts" />
<QuestionButton label="Контакт — ЛПР?" v-model="form.dm_contact" :options="dmContactOpts" />
<QuestionButton
v-if="form.lead_type === 'inbound'"
label="Источник лида"
v-model="form.source"
:options="sourceOpts"
/>
<QuestionButton
v-else
label="Предыдущий контакт"
v-model="form.prev_contact"
:options="prevContactOpts"
/>
</section>
<section class="space-y-4">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Сделка</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Этап воронки</label>
<select
v-model="form.deal_stage"
class="w-full border border-gray-300 rounded-lg px-3 py-2 bg-white"
>
<option v-for="s in DEAL_STAGES" :key="s" :value="s">
{{ DEAL_STAGE_LABELS[s] }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Ожидаемая сумма</label>
<input
v-model="form.expected_amount"
type="number"
min="0"
step="0.01"
placeholder="500000"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">Следующее касание</label>
<input
v-model="form.next_action_date"
type="date"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium mb-1">Комментарий</label>
<textarea
v-model="form.comment"
rows="3"
placeholder="О чём договорились, что нужно уточнить…"
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
</div>
</section>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<div class="flex gap-3">
<button
type="submit"
:disabled="!canSubmit || loading"
class="bg-gray-900 text-white rounded-lg px-5 py-2 font-medium hover:bg-gray-800 disabled:opacity-50"
>
{{ loading ? 'Сохраняем…' : 'Создать' }}
</button>
<button
type="button"
class="text-gray-600 hover:text-gray-900 px-5 py-2"
@click="$router.push({ name: 'leads' })"
>
Отмена
</button>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
theme: {
extend: {
colors: {
cold: '#ef4444',
warm: '#eab308',
hot: '#22c55e',
},
},
},
plugins: [],
};

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'node:path';
export default defineConfig({
base: process.env.VITE_BASE ?? '/',
plugins: [vue()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
},
},
});

View File

@ -0,0 +1,48 @@
-- Up Migration
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_name TEXT NOT NULL,
company_name TEXT NOT NULL,
lead_type TEXT NOT NULL CHECK (lead_type IN ('inbound', 'outbound')),
company_size TEXT NOT NULL CHECK (company_size IN ('1-10', '11-50', '50+')),
budget TEXT NOT NULL CHECK (budget IN ('no', 'maybe', 'confirmed')),
urgency TEXT NOT NULL CHECK (urgency IN ('none', '3-6m', 'under-3m')),
dm_contact TEXT NOT NULL CHECK (dm_contact IN ('no', 'influencer', 'decision-maker')),
source TEXT CHECK (source IN ('cold', 'referral', 'website')),
prev_contact TEXT CHECK (prev_contact IN ('none', 'once', 'well-known')),
icp_fit BOOLEAN NOT NULL,
score INT NOT NULL CHECK (score BETWEEN 0 AND 100),
status TEXT NOT NULL CHECK (status IN ('cold', 'warm', 'hot')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by TEXT NOT NULL,
CONSTRAINT lead_type_fields CHECK (
(lead_type = 'inbound' AND source IS NOT NULL AND prev_contact IS NULL) OR
(lead_type = 'outbound' AND prev_contact IS NOT NULL AND source IS NULL)
)
);
CREATE INDEX leads_score_desc ON leads (score DESC);
CREATE INDEX leads_status ON leads (status);
CREATE INDEX leads_lead_type ON leads (lead_type);
CREATE TABLE score_history (
id BIGSERIAL PRIMARY KEY,
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
total INT NOT NULL,
breakdown JSONB NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
changed_by TEXT NOT NULL
);
CREATE INDEX score_history_lead ON score_history (lead_id, changed_at DESC);
-- Down Migration
-- DROP TABLE IF EXISTS score_history;
-- DROP TABLE IF EXISTS leads;

View File

@ -0,0 +1,6 @@
-- Up Migration
ALTER TABLE leads ADD COLUMN contact_phone TEXT NOT NULL DEFAULT '';
ALTER TABLE leads ALTER COLUMN contact_phone DROP DEFAULT;
-- Down Migration
-- ALTER TABLE leads DROP COLUMN contact_phone;

View File

@ -0,0 +1,29 @@
-- Up Migration
ALTER TABLE leads
ADD COLUMN email TEXT NOT NULL DEFAULT '',
ADD COLUMN position TEXT NOT NULL DEFAULT '',
ADD COLUMN website TEXT NOT NULL DEFAULT '',
ADD COLUMN industry TEXT NOT NULL DEFAULT '',
ADD COLUMN city TEXT NOT NULL DEFAULT '',
ADD COLUMN comment TEXT NOT NULL DEFAULT '',
ADD COLUMN next_action_date DATE,
ADD COLUMN expected_amount NUMERIC(14,2),
ADD COLUMN deal_stage TEXT NOT NULL DEFAULT 'new'
CHECK (deal_stage IN ('new','contacted','qualified','proposal','won','lost'));
CREATE INDEX leads_deal_stage ON leads (deal_stage);
CREATE INDEX leads_next_action_date ON leads (next_action_date);
-- Down Migration
-- DROP INDEX IF EXISTS leads_next_action_date;
-- DROP INDEX IF EXISTS leads_deal_stage;
-- ALTER TABLE leads
-- DROP COLUMN deal_stage,
-- DROP COLUMN expected_amount,
-- DROP COLUMN next_action_date,
-- DROP COLUMN comment,
-- DROP COLUMN city,
-- DROP COLUMN industry,
-- DROP COLUMN website,
-- DROP COLUMN position,
-- DROP COLUMN email;

View File

@ -0,0 +1,11 @@
-- Up Migration
UPDATE leads SET deal_stage = 'contacted' WHERE deal_stage = 'qualified';
ALTER TABLE leads DROP CONSTRAINT leads_deal_stage_check;
ALTER TABLE leads ADD CONSTRAINT leads_deal_stage_check
CHECK (deal_stage IN ('new','contacted','proposal','won','lost'));
-- Down Migration
-- ALTER TABLE leads DROP CONSTRAINT leads_deal_stage_check;
-- ALTER TABLE leads ADD CONSTRAINT leads_deal_stage_check
-- CHECK (deal_stage IN ('new','contacted','qualified','proposal','won','lost'));

View File

@ -0,0 +1,6 @@
-- Up Migration
ALTER TABLE leads DROP COLUMN icp_fit;
-- Down Migration
-- ALTER TABLE leads ADD COLUMN icp_fit BOOLEAN NOT NULL DEFAULT false;
-- ALTER TABLE leads ALTER COLUMN icp_fit DROP DEFAULT;

View File

@ -0,0 +1,2 @@
FROM nginx:alpine
COPY default.conf /etc/nginx/conf.d/default.conf

View File

@ -0,0 +1,21 @@
server {
listen 80;
client_max_body_size 2m;
location /api/ {
proxy_pass http://leadscore-be:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://leadscore-fe:80/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}