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:
commit
8491537195
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
95
PRD.md
Normal 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
452
TRD.md
Normal 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
726
Zorro.md
Normal 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
11
leadscore/.env.example
Normal 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
7
leadscore/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
coverage
|
||||
91
leadscore/README.md
Normal file
91
leadscore/README.md
Normal 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)
|
||||
```
|
||||
6
leadscore/backend/.dockerignore
Normal file
6
leadscore/backend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
test
|
||||
vitest.config.ts
|
||||
19
leadscore/backend/Dockerfile
Normal file
19
leadscore/backend/Dockerfile
Normal 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"]
|
||||
30
leadscore/backend/package.json
Normal file
30
leadscore/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
leadscore/backend/src/auth.ts
Normal file
48
leadscore/backend/src/auth.ts
Normal 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}`);
|
||||
}
|
||||
17
leadscore/backend/src/config.ts
Normal file
17
leadscore/backend/src/config.ts
Normal 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,
|
||||
};
|
||||
16
leadscore/backend/src/db.ts
Normal file
16
leadscore/backend/src/db.ts
Normal 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);
|
||||
}
|
||||
32
leadscore/backend/src/errors.ts
Normal file
32
leadscore/backend/src/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
leadscore/backend/src/routes/auth.ts
Normal file
25
leadscore/backend/src/routes/auth.ts
Normal 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 } };
|
||||
},
|
||||
);
|
||||
}
|
||||
5
leadscore/backend/src/routes/health.ts
Normal file
5
leadscore/backend/src/routes/health.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
}
|
||||
443
leadscore/backend/src/routes/leads.ts
Normal file
443
leadscore/backend/src/routes/leads.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
71
leadscore/backend/src/scoring.ts
Normal file
71
leadscore/backend/src/scoring.ts
Normal 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) };
|
||||
}
|
||||
53
leadscore/backend/src/server.ts
Normal file
53
leadscore/backend/src/server.ts
Normal 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);
|
||||
});
|
||||
73
leadscore/backend/src/types.ts
Normal file
73
leadscore/backend/src/types.ts
Normal 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;
|
||||
}
|
||||
128
leadscore/backend/test/scoring.test.ts
Normal file
128
leadscore/backend/test/scoring.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
18
leadscore/backend/tsconfig.json
Normal file
18
leadscore/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
86
leadscore/docker-compose.yaml
Normal file
86
leadscore/docker-compose.yaml
Normal 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
|
||||
4
leadscore/frontend/.dockerignore
Normal file
4
leadscore/frontend/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
17
leadscore/frontend/Dockerfile
Normal file
17
leadscore/frontend/Dockerfile
Normal 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
15
leadscore/frontend/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
12
leadscore/frontend/index.html
Normal file
12
leadscore/frontend/index.html
Normal 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>
|
||||
16
leadscore/frontend/nginx.conf
Normal file
16
leadscore/frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
30
leadscore/frontend/package.json
Normal file
30
leadscore/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
leadscore/frontend/postcss.config.js
Normal file
6
leadscore/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
54
leadscore/frontend/src/App.vue
Normal file
54
leadscore/frontend/src/App.vue
Normal 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>
|
||||
13
leadscore/frontend/src/api/auth.ts
Normal file
13
leadscore/frontend/src/api/auth.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
59
leadscore/frontend/src/api/client.ts
Normal file
59
leadscore/frontend/src/api/client.ts
Normal 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;
|
||||
}
|
||||
164
leadscore/frontend/src/api/leads.ts
Normal file
164
leadscore/frontend/src/api/leads.ts
Normal 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`);
|
||||
}
|
||||
86
leadscore/frontend/src/components/FilterBar.vue
Normal file
86
leadscore/frontend/src/components/FilterBar.vue
Normal 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>
|
||||
40
leadscore/frontend/src/components/QuestionButton.vue
Normal file
40
leadscore/frontend/src/components/QuestionButton.vue
Normal 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>
|
||||
38
leadscore/frontend/src/components/ScoreBadge.vue
Normal file
38
leadscore/frontend/src/components/ScoreBadge.vue
Normal 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>
|
||||
19
leadscore/frontend/src/main.ts
Normal file
19
leadscore/frontend/src/main.ts
Normal 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');
|
||||
42
leadscore/frontend/src/router.ts
Normal file
42
leadscore/frontend/src/router.ts
Normal 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;
|
||||
45
leadscore/frontend/src/stores/auth.ts
Normal file
45
leadscore/frontend/src/stores/auth.ts
Normal 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');
|
||||
},
|
||||
},
|
||||
});
|
||||
63
leadscore/frontend/src/stores/leads.ts
Normal file
63
leadscore/frontend/src/stores/leads.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
10
leadscore/frontend/src/style.css
Normal file
10
leadscore/frontend/src/style.css
Normal 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;
|
||||
}
|
||||
357
leadscore/frontend/src/views/LeadCardView.vue
Normal file
357
leadscore/frontend/src/views/LeadCardView.vue
Normal 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>
|
||||
130
leadscore/frontend/src/views/LeadsListView.vue
Normal file
130
leadscore/frontend/src/views/LeadsListView.vue
Normal 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>
|
||||
69
leadscore/frontend/src/views/LoginView.vue
Normal file
69
leadscore/frontend/src/views/LoginView.vue
Normal 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>
|
||||
346
leadscore/frontend/src/views/NewLeadView.vue
Normal file
346
leadscore/frontend/src/views/NewLeadView.vue
Normal 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>
|
||||
14
leadscore/frontend/tailwind.config.js
Normal file
14
leadscore/frontend/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
21
leadscore/frontend/tsconfig.json
Normal file
21
leadscore/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
leadscore/frontend/tsconfig.node.json
Normal file
11
leadscore/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
leadscore/frontend/vite.config.ts
Normal file
21
leadscore/frontend/vite.config.ts
Normal 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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
48
leadscore/migrations/1700000000000_init.sql
Normal file
48
leadscore/migrations/1700000000000_init.sql
Normal 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;
|
||||
6
leadscore/migrations/1700000001000_add_contact_phone.sql
Normal file
6
leadscore/migrations/1700000001000_add_contact_phone.sql
Normal 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;
|
||||
29
leadscore/migrations/1700000002000_add_crm_fields.sql
Normal file
29
leadscore/migrations/1700000002000_add_crm_fields.sql
Normal 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;
|
||||
11
leadscore/migrations/1700000003000_drop_qualified_stage.sql
Normal file
11
leadscore/migrations/1700000003000_drop_qualified_stage.sql
Normal 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'));
|
||||
6
leadscore/migrations/1700000004000_drop_icp_fit.sql
Normal file
6
leadscore/migrations/1700000004000_drop_icp_fit.sql
Normal 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;
|
||||
2
leadscore/nginx/Dockerfile
Normal file
2
leadscore/nginx/Dockerfile
Normal file
@ -0,0 +1,2 @@
|
||||
FROM nginx:alpine
|
||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||
21
leadscore/nginx/default.conf
Normal file
21
leadscore/nginx/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user