feat: complete backend B1-B7 — Payload CMS, ezy payments, leads, deploy
- B1: Next.js 15 + Payload CMS 3.0 + Postgres 16, ESLint, Prettier, Husky, Vitest - B2: 9 collections, 6 globals, 12 Page Builder blocks, access control, slugify/revalidate hooks - B3: ezy.com.ua payments, Binotel HMAC webhook, leads API, Telegram bot, Resend email, rate limiting - B4: Tariffs collection with ezy API sync (cron + manual), dynamic pricing source-of-truth - B5: 13 test files covering unit libs and all API routes - B6: Dockerfile multi-stage, docker-compose.prod.yml, nginx.conf SSL, GitHub Actions CI/CD, health endpoint - B7: docs/admin-guide-ua.md (marketer guide), docs/deploy.md (VPS instructions), README quickstart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
83fad62732
commit
9b41fa447a
113 changed files with 15923 additions and 0 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
coverage
|
||||
.claude
|
||||
.claude-flow
|
||||
.swarm
|
||||
ruvector.db
|
||||
agentdb.rvf
|
||||
agentdb.rvf.lock
|
||||
59
.github/workflows/ci.yml
vendored
Normal file
59
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run type check
|
||||
run: pnpm typecheck
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
81
.github/workflows/deploy.yml
vendored
Normal file
81
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/shumiland
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
environment: production
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
envs: IMAGE_TAG
|
||||
script: |
|
||||
cd /opt/shumiland
|
||||
TAG="$IMAGE_TAG" docker compose -f docker-compose.prod.yml pull app
|
||||
TAG="$IMAGE_TAG" docker compose -f docker-compose.prod.yml up -d --no-deps app
|
||||
docker system prune -f --filter "until=24h"
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
|
||||
|
||||
- name: Health check
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
sleep 10
|
||||
curl -sf http://localhost:3000/api/health || exit 1
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -25,3 +25,23 @@ Thumbs.db
|
|||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Payload generated
|
||||
src/payload-types.ts
|
||||
src/generated-schema.graphql
|
||||
|
||||
# Build
|
||||
build/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Migrations (auto-generated, committed separately)
|
||||
# migrations/*.ts
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
|
|
|||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
|
|
@ -0,0 +1 @@
|
|||
npx lint-staged
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
shamefully-hoist=false
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Base ----
|
||||
FROM node:22-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
# ---- Dependencies ----
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- Builder ----
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm run build
|
||||
|
||||
# ---- Runner ----
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
57
README.md
57
README.md
|
|
@ -1,2 +1,59 @@
|
|||
# Shumiland
|
||||
|
||||
Website for Shumiland entertainment park — Next.js 15 + Payload CMS 3.0 + PostgreSQL 16.
|
||||
|
||||
## Quickstart (dev)
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Copy env and fill in values
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Start database
|
||||
docker compose up postgres -d
|
||||
|
||||
# 4. Run dev server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000/admin](http://localhost:3000/admin) to access the CMS admin.
|
||||
|
||||
On first run, Payload will prompt you to create an admin user.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | --------------------------------- |
|
||||
| `pnpm dev` | Start dev server with hot reload |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm start` | Start production server |
|
||||
| `pnpm lint` | ESLint check |
|
||||
| `pnpm typecheck` | TypeScript check |
|
||||
| `pnpm test` | Run tests |
|
||||
| `pnpm format` | Prettier format |
|
||||
| `pnpm seed` | Seed initial admin user + globals |
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/api/ # API routes (leads, tickets, binotel, health, revalidate)
|
||||
├── collections/ # Payload CMS collections (Pages, Blog, Tariffs, Leads, Orders, ...)
|
||||
├── globals/ # Payload globals (Header, Footer, HomePage, SiteSettings, ...)
|
||||
├── blocks/ # Page Builder blocks (Hero, Gallery, LeadForm, PricingBlock, ...)
|
||||
├── lib/ # Integrations (ezy, binotel, telegram, resend, rateLimit, ...)
|
||||
└── access/ # Access control helpers
|
||||
tests/
|
||||
├── unit/ # Unit tests (lib functions)
|
||||
└── api/ # API route tests
|
||||
docs/
|
||||
├── admin-guide-ua.md # CMS guide for marketers (Ukrainian)
|
||||
└── deploy.md # VPS deployment instructions
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [Admin guide (UA)](docs/admin-guide-ua.md) — for marketers, no technical knowledge required
|
||||
- [Deploy guide](docs/deploy.md) — VPS setup, secrets, CI/CD, backup/restore
|
||||
|
|
|
|||
70
docker-compose.prod.yml
Normal file
70
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: shumiland
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: shumiland
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U shumiland']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
image: ghcr.io/aimpress/shumiland:${TAG:-latest}
|
||||
env_file: .env.production
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
command: >-
|
||||
certonly --webroot
|
||||
--webroot-path=/var/www/certbot
|
||||
--email ${CERTBOT_EMAIL}
|
||||
--agree-tos
|
||||
--no-eff-email
|
||||
-d ${CERTBOT_DOMAIN}
|
||||
|
||||
pg_backup:
|
||||
image: alpine:3
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data:ro
|
||||
- ./backups:/backups
|
||||
environment:
|
||||
POSTGRES_USER: shumiland
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: shumiland
|
||||
POSTGRES_HOST: postgres
|
||||
command: >-
|
||||
sh -c "apk add --no-cache postgresql-client &&
|
||||
echo '0 3 * * * pg_dump postgresql://$$POSTGRES_USER:$$POSTGRES_PASSWORD@$$POSTGRES_HOST/$$POSTGRES_DB | gzip > /backups/shumiland_$$(date +\%Y\%m\%d_\%H\%M\%S).sql.gz && find /backups -mtime +14 -delete' | crontab - &&
|
||||
crond -f"
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
version: '3.9'
|
||||
|
||||
# Dev compose — only Postgres. Run `pnpm dev` locally for the app.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_USER: shumiland
|
||||
POSTGRES_PASSWORD: shumiland # dev only — override via .env in production
|
||||
POSTGRES_DB: shumiland
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U shumiland']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
176
docs/admin-guide-ua.md
Normal file
176
docs/admin-guide-ua.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Керівництво адміністратора — Shumiland
|
||||
|
||||
Цей посібник для маркетологів та контент-менеджерів. Технічних знань не потрібно.
|
||||
|
||||
---
|
||||
|
||||
## Вхід в адмінпанель
|
||||
|
||||
1. Відкрийте браузер і перейдіть за адресою: **https://shumiland.com.ua/admin**
|
||||
2. Введіть email та пароль, які вам надали.
|
||||
3. Натисніть **«Увійти»**.
|
||||
|
||||
> Якщо забули пароль — зверніться до розробника для скидання.
|
||||
|
||||
---
|
||||
|
||||
## Навігація
|
||||
|
||||
Ліве меню містить:
|
||||
|
||||
| Розділ | Призначення |
|
||||
| --------------- | ------------------------------------ |
|
||||
| **Сторінки** | Всі сторінки сайту |
|
||||
| **Блог** | Статті та новини |
|
||||
| **Тарифи** | Квитки (ціни тягнуться з каси ezy) |
|
||||
| **Ліди** | Заявки від відвідувачів |
|
||||
| **Замовлення** | Замовлення квитків |
|
||||
| **Медіа** | Зображення та файли |
|
||||
| **Globals** | Шапка, підвал, Головна, Налаштування |
|
||||
| **Користувачі** | Облікові записи адмінпанелі |
|
||||
|
||||
---
|
||||
|
||||
## Сторінки
|
||||
|
||||
### Створення нової сторінки
|
||||
|
||||
1. Натисніть **«Сторінки»** → **«Створити»**.
|
||||
2. Заповніть поле **«Заголовок»**.
|
||||
3. Поле **«Slug»** (адреса сторінки) заповниться автоматично, наприклад `pro-park`.
|
||||
- Можна відредагувати вручну — лише латинські літери, цифри та дефіси.
|
||||
4. Перетягуйте блоки з панелі **«Блоки»** для побудови структури сторінки.
|
||||
5. Натисніть **«Зберегти чернетку»** — сторінка не буде видна відвідувачам.
|
||||
6. Коли готово — натисніть **«Опублікувати»**.
|
||||
|
||||
### Редагування існуючої сторінки
|
||||
|
||||
1. **Сторінки** → клік на назву.
|
||||
2. Відредагуйте потрібні блоки.
|
||||
3. **«Зберегти чернетку»** або **«Опублікувати»**.
|
||||
|
||||
### Приховати / знову показати сторінку
|
||||
|
||||
- Відкрийте сторінку → змініть **Статус** на **«Чернетка»** → **«Зберегти»**.
|
||||
- Сторінка зникне з сайту, але не видалиться.
|
||||
|
||||
---
|
||||
|
||||
## Блоки сторінки (Page Builder)
|
||||
|
||||
Кожен блок можна додати кількаразово та перетягувати у будь-якому порядку.
|
||||
|
||||
| Блок | Що робить |
|
||||
| ---------------- | ---------------------------------------------------------- |
|
||||
| **Hero** | Банер з фоновим відео або зображенням + заголовок + кнопка |
|
||||
| **Текст** | Форматований текст (жирний, курсив, списки, посилання) |
|
||||
| **Зображення** | Одне зображення з підписом |
|
||||
| **Відео** | Вбудоване відео (YouTube / Vimeo) |
|
||||
| **Галерея** | Кілька фото у сітці |
|
||||
| **Форма заявки** | Налаштовувана контактна форма |
|
||||
| **Тарифи** | Блок вибору квитків (дані з каси ezy) |
|
||||
| **CTA** | Заклик до дії — кнопка + підзаголовок |
|
||||
| **Локації** | Картки локацій парку |
|
||||
| **Переваги** | «Чому обирають Shumiland» |
|
||||
| **Новини** | Останні N статей з блогу |
|
||||
| **Підписка** | Форма підписки на розсилку |
|
||||
| **Rich Text** | Повнофункціональний редактор Lexical |
|
||||
|
||||
---
|
||||
|
||||
## Медіа (завантаження фото)
|
||||
|
||||
1. **Медіа** → **«Завантажити»**.
|
||||
2. Виберіть файл (JPG, PNG, WebP, SVG — до 20 МБ).
|
||||
3. Обов'язково заповніть поле **«Alt текст»** — це важливо для SEO та доступності.
|
||||
4. Натисніть **«Зберегти»**.
|
||||
|
||||
> Завантажені файли одразу доступні у всіх блоках при виборі зображення.
|
||||
|
||||
---
|
||||
|
||||
## Globals (шапка, підвал, головна)
|
||||
|
||||
**Globals** — це глобальні елементи сайту, які існують в одному екземплярі.
|
||||
|
||||
### Шапка (Header)
|
||||
|
||||
**Globals → Header**
|
||||
|
||||
- Змініть логотип або навігаційні посилання.
|
||||
- Натисніть **«Зберегти»** — зміни з'являться на всіх сторінках одразу.
|
||||
|
||||
### Підвал (Footer)
|
||||
|
||||
**Globals → Footer**
|
||||
|
||||
- Контакти, посилання в соцмережах, копірайт.
|
||||
|
||||
### Головна сторінка
|
||||
|
||||
**Globals → HomePage**
|
||||
|
||||
- Керуйте блоками головної: hero, локації, переваги, новини, підписка.
|
||||
|
||||
### Налаштування сайту
|
||||
|
||||
**Globals → SiteSettings**
|
||||
|
||||
- GA4 ID (аналітика), Binotel ID (телефонія).
|
||||
|
||||
---
|
||||
|
||||
## Блог
|
||||
|
||||
### Створення статті
|
||||
|
||||
1. **Блог** → **«Створити»**.
|
||||
2. Заповніть **«Заголовок»**, **«Slug»** (автозаповнення), **«Зображення-обкладинка»**.
|
||||
3. Напишіть текст у редакторі.
|
||||
4. Виберіть **Категорії** та **Теги**.
|
||||
5. **«Опублікувати»** або **«Зберегти чернетку»**.
|
||||
|
||||
---
|
||||
|
||||
## Тарифи (квитки)
|
||||
|
||||
### Важливо
|
||||
|
||||
Ціни та назви тарифів **завжди беруться з каси ezy** автоматично. Ви не можете змінити ціну — тільки касир ezy.
|
||||
|
||||
### Що можна змінити в адмінпанелі
|
||||
|
||||
**Тарифи** → виберіть тариф:
|
||||
|
||||
| Поле | Опис |
|
||||
| ----------------------- | -------------------------------------------- |
|
||||
| **Відображувана назва** | Замінює стандартну назву з ezy (опціонально) |
|
||||
| **Опис** | Короткий текст під назвою квитка |
|
||||
| **Зображення** | Фото для картки тарифу |
|
||||
| **Категорія** | Групування: динопарк / лабіринт / сімейний |
|
||||
| **Сортування** | Порядок відображення (менше = вище) |
|
||||
| **Видимий** | Приховати тариф з сайту (не видаляє з ezy) |
|
||||
|
||||
### Синхронізація тарифів з ezy
|
||||
|
||||
Синхронізація відбувається автоматично щогодини. Щоб оновити вручну:
|
||||
|
||||
1. **Тарифи** → кнопка **«Синхронізувати з ezy»** (якщо є) або зверніться до розробника.
|
||||
|
||||
---
|
||||
|
||||
## Ліди та замовлення
|
||||
|
||||
- **Ліди** — заявки з форм сайту (ім'я, телефон, джерело). Статуси: `new`, `contacted`, `closed`.
|
||||
- **Замовлення** — записи після натискання «Оплатити». Статус `redirected_to_payment` означає, що людина перейшла на оплату. Фактичну оплату підтверджує каса ezy.
|
||||
|
||||
> Telegram-бот надсилає сповіщення одразу після появи нового ліду або замовлення.
|
||||
|
||||
---
|
||||
|
||||
## Швидкі підказки
|
||||
|
||||
- Завжди **публікуйте** сторінку, коли хочете показати її відвідувачам — «Зберегти чернетку» не публікує.
|
||||
- Для видалення — лише якщо впевнені; краще використовуйте **приховування** (статус «Чернетка»).
|
||||
- Alt текст у медіа — **обов'язково** для всіх зображень.
|
||||
- Slug **не змінюйте** після публікації — це зламає посилання на сторінку.
|
||||
244
docs/deploy.md
Normal file
244
docs/deploy.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# Deployment Guide — Shumiland VPS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- VPS with Ubuntu 22.04+, Docker + Docker Compose v2 installed
|
||||
- Domain `shumiland.com.ua` DNS A-record pointing to VPS IP
|
||||
- SSH access as non-root user with sudo
|
||||
- GitHub Actions secrets configured (see CI/CD section)
|
||||
|
||||
---
|
||||
|
||||
## First Deploy
|
||||
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone git@github.com:aimpress/shumiland-site-dev.git /opt/shumiland
|
||||
cd /opt/shumiland
|
||||
```
|
||||
|
||||
### 2. Create `.env.production`
|
||||
|
||||
```bash
|
||||
cp .env.example .env.production
|
||||
nano .env.production
|
||||
```
|
||||
|
||||
Fill in all values (see table below). Generate secrets with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------- | -------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgresql://shumiland:<POSTGRES_PASSWORD>@postgres:5432/shumiland` |
|
||||
| `POSTGRES_PASSWORD` | Generate with openssl |
|
||||
| `PAYLOAD_SECRET` | Generate with openssl |
|
||||
| `REVALIDATE_SECRET` | Generate with openssl |
|
||||
| `SYNC_SECRET` | Generate with openssl |
|
||||
| `CRON_SECRET` | Generate with openssl |
|
||||
| `EZY_PARTNER_KEY` | From ezy.com.ua dashboard |
|
||||
| `EZY_ACTIVITY` | From ezy.com.ua dashboard |
|
||||
| `TELEGRAM_BOT_TOKEN` | From BotFather |
|
||||
| `TELEGRAM_CHAT_ID` | Manager group chat ID |
|
||||
| `RESEND_API_KEY` | From resend.com |
|
||||
| `MANAGER_EMAILS` | Comma-separated manager emails |
|
||||
| `BINOTEL_HMAC_SECRET` | From Binotel dashboard → Webhooks |
|
||||
| `CERTBOT_DOMAIN` | `shumiland.com.ua` |
|
||||
| `CERTBOT_EMAIL` | admin@ai-impress.com |
|
||||
| `NEXT_PUBLIC_SITE_URL` | `https://shumiland.com.ua` |
|
||||
|
||||
### 3. Obtain SSL certificate
|
||||
|
||||
Run certbot once to get the certificate before starting nginx:
|
||||
|
||||
```bash
|
||||
# Start only postgres and certbot (nginx needs cert to start)
|
||||
docker compose -f docker-compose.prod.yml run --rm certbot
|
||||
|
||||
# Verify cert created
|
||||
ls certbot/conf/live/shumiland.com.ua/
|
||||
```
|
||||
|
||||
### 4. Pull image and start all services
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 5. Run database migrations
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec app npx payload migrate
|
||||
```
|
||||
|
||||
### 6. Seed initial data and sync tariffs
|
||||
|
||||
```bash
|
||||
# Create admin user + seed globals
|
||||
docker compose -f docker-compose.prod.yml exec app pnpm seed
|
||||
|
||||
# Sync tariffs from ezy API
|
||||
curl -X POST https://shumiland.com.ua/api/tariffs/sync \
|
||||
-H "Authorization: Bearer <SYNC_SECRET>"
|
||||
```
|
||||
|
||||
### 7. Verify health
|
||||
|
||||
```bash
|
||||
curl https://shumiland.com.ua/api/health
|
||||
# Expected: {"status":"healthy","db":"ok","ezy":"ok","ts":...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions CI/CD
|
||||
|
||||
### Required Secrets (Settings → Secrets and variables → Actions)
|
||||
|
||||
| Secret | Description |
|
||||
| ------------- | -------------------------------------------------- |
|
||||
| `GHCR_TOKEN` | GitHub Personal Access Token with `write:packages` |
|
||||
| `VPS_HOST` | VPS IP address |
|
||||
| `VPS_USER` | SSH username |
|
||||
| `VPS_SSH_KEY` | Private SSH key (`cat ~/.ssh/id_ed25519`) |
|
||||
| `VPS_PORT` | SSH port (default: 22) |
|
||||
|
||||
### Deploy Flow
|
||||
|
||||
Every push to `main`:
|
||||
|
||||
1. CI runs lint + typecheck + tests + build
|
||||
2. Docker image is built and pushed to `ghcr.io/aimpress/shumiland:<sha>`
|
||||
3. SSH into VPS → `docker compose pull && docker compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## Routine Operations
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
cd /opt/shumiland
|
||||
docker compose -f docker-compose.prod.yml restart app
|
||||
```
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
# App logs (last 100 lines, follow)
|
||||
docker compose -f docker-compose.prod.yml logs -f --tail=100 app
|
||||
|
||||
# Nginx access logs
|
||||
docker compose -f docker-compose.prod.yml logs -f nginx
|
||||
```
|
||||
|
||||
### Check service status
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
### Update to latest image manually
|
||||
|
||||
```bash
|
||||
cd /opt/shumiland
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Backup & Restore
|
||||
|
||||
### Manual backup
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec postgres \
|
||||
pg_dump -U shumiland shumiland | gzip > /tmp/manual-backup-$(date +%Y%m%d).sql.gz
|
||||
```
|
||||
|
||||
### Automated backups
|
||||
|
||||
The `pg_backup` service runs daily at 03:00 UTC. Backups are stored in `./backups/` and older than 14 days are deleted automatically.
|
||||
|
||||
```bash
|
||||
# List existing backups
|
||||
ls -lh /opt/shumiland/backups/
|
||||
```
|
||||
|
||||
### Restore from backup
|
||||
|
||||
```bash
|
||||
# Stop the app (keep postgres running)
|
||||
docker compose -f docker-compose.prod.yml stop app
|
||||
|
||||
# Restore
|
||||
gunzip -c /opt/shumiland/backups/<filename>.sql.gz | \
|
||||
docker compose -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U shumiland shumiland
|
||||
|
||||
# Start app
|
||||
docker compose -f docker-compose.prod.yml start app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSL Certificate Renewal
|
||||
|
||||
Certbot renews automatically via the certbot container. To renew manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml run --rm certbot renew
|
||||
docker compose -f docker-compose.prod.yml restart nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App fails to start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose -f docker-compose.prod.yml logs app
|
||||
|
||||
# Common causes:
|
||||
# - Missing .env.production variable → check all required vars are set
|
||||
# - DB not ready → check postgres health: docker compose ps
|
||||
# - Port 3000 conflict → check nothing else runs on port 3000
|
||||
```
|
||||
|
||||
### nginx returns 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# Check if app container is running
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Check app logs for startup errors
|
||||
docker compose -f docker-compose.prod.yml logs --tail=50 app
|
||||
```
|
||||
|
||||
### Database connection error
|
||||
|
||||
```bash
|
||||
# Test DB connectivity
|
||||
docker compose -f docker-compose.prod.yml exec postgres \
|
||||
psql -U shumiland -c "SELECT 1"
|
||||
|
||||
# Check DATABASE_URL in .env.production matches POSTGRES_PASSWORD
|
||||
```
|
||||
|
||||
### Tariffs not syncing
|
||||
|
||||
```bash
|
||||
# Manual sync
|
||||
curl -X POST https://shumiland.com.ua/api/tariffs/sync \
|
||||
-H "Authorization: Bearer <SYNC_SECRET>" -v
|
||||
|
||||
# Check EZY_ACTIVITY and EZY_PARTNER_KEY are set
|
||||
docker compose -f docker-compose.prod.yml exec app env | grep EZY
|
||||
```
|
||||
26
eslint.config.mjs
Normal file
26
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals'),
|
||||
{
|
||||
plugins: { '@typescript-eslint': tsPlugin },
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
0
migrations/.gitkeep
Normal file
0
migrations/.gitkeep
Normal file
19
next.config.ts
Normal file
19
next.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '3000',
|
||||
pathname: '/media/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
36
nginx.conf
Normal file
36
nginx.conf
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name shumiland.com.ua www.shumiland.com.ua;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name shumiland.com.ua www.shumiland.com.ua;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/shumiland.com.ua/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/shumiland.com.ua/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
76
package.json
Normal file
76
package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "shumiland-site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.0.9",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"payload": "payload",
|
||||
"prepare": "husky",
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{js,mjs,cjs,json,md,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "^3.33.0",
|
||||
"@payloadcms/next": "^3.33.0",
|
||||
"@payloadcms/richtext-lexical": "^3.33.0",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"cyrillic-to-translit-js": "^3.2.1",
|
||||
"graphql": "^16.9.0",
|
||||
"next": "^15.3.2",
|
||||
"payload": "^3.33.0",
|
||||
"pino": "^9.6.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-email": "^6.1.1",
|
||||
"resend": "^6.12.3",
|
||||
"sharp": "^0.33.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||
"@typescript-eslint/parser": "^8.32.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
||||
"supertest": "^7.1.0",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
72
payload.config.ts
Normal file
72
payload.config.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { buildConfig } from 'payload'
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import sharp from 'sharp'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Users } from './src/collections/Users'
|
||||
import { Media } from './src/collections/Media'
|
||||
import { Pages } from './src/collections/Pages'
|
||||
import { BlogPosts } from './src/collections/BlogPosts'
|
||||
import { Categories } from './src/collections/Categories'
|
||||
import { Tags } from './src/collections/Tags'
|
||||
import { Tariffs } from './src/collections/Tariffs'
|
||||
import { Leads } from './src/collections/Leads'
|
||||
import { Orders } from './src/collections/Orders'
|
||||
|
||||
import { HomePage } from './src/globals/HomePage'
|
||||
import { CheckoutPage } from './src/globals/CheckoutPage'
|
||||
import { ThankYouPage } from './src/globals/ThankYouPage'
|
||||
import { Header } from './src/globals/Header'
|
||||
import { Footer } from './src/globals/Footer'
|
||||
import { SiteSettings } from './src/globals/SiteSettings'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
const siteURL = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
|
||||
|
||||
export default buildConfig({
|
||||
secret: process.env['PAYLOAD_SECRET']!,
|
||||
serverURL: siteURL,
|
||||
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env['DATABASE_URL']!,
|
||||
},
|
||||
push: false,
|
||||
migrationDir: path.resolve(dirname, 'migrations'),
|
||||
}),
|
||||
|
||||
editor: lexicalEditor(),
|
||||
sharp,
|
||||
|
||||
collections: [Users, Media, Pages, BlogPosts, Categories, Tags, Tariffs, Leads, Orders],
|
||||
|
||||
globals: [HomePage, CheckoutPage, ThankYouPage, Header, Footer, SiteSettings],
|
||||
|
||||
admin: {
|
||||
user: 'users',
|
||||
livePreview: {
|
||||
url: siteURL,
|
||||
collections: ['pages'],
|
||||
globals: [
|
||||
'home-page',
|
||||
'checkout-page',
|
||||
'thank-you-page',
|
||||
'header',
|
||||
'footer',
|
||||
'site-settings',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
cors: [siteURL],
|
||||
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'src/payload-types.ts'),
|
||||
},
|
||||
|
||||
telemetry: false,
|
||||
})
|
||||
10330
pnpm-lock.yaml
generated
Normal file
10330
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
0
src/access/.gitkeep
Normal file
0
src/access/.gitkeep
Normal file
3
src/access/isAdmin.ts
Normal file
3
src/access/isAdmin.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { Access } from 'payload'
|
||||
|
||||
export const isAdmin: Access = ({ req: { user } }) => user?.role === 'admin'
|
||||
4
src/access/isAdminOrEditor.ts
Normal file
4
src/access/isAdminOrEditor.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { Access } from 'payload'
|
||||
|
||||
export const isAdminOrEditor: Access = ({ req: { user } }) =>
|
||||
user?.role === 'admin' || user?.role === 'editor'
|
||||
6
src/access/isAuthenticatedOrPublished.ts
Normal file
6
src/access/isAuthenticatedOrPublished.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { Access } from 'payload'
|
||||
|
||||
export const isAuthenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { _status: { equals: 'published' } }
|
||||
}
|
||||
14
src/app/(frontend)/layout.tsx
Normal file
14
src/app/(frontend)/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | Shumiland',
|
||||
default: 'Shumiland',
|
||||
},
|
||||
description: 'Shumiland — coming soon.',
|
||||
}
|
||||
|
||||
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
8
src/app/(frontend)/page.tsx
Normal file
8
src/app/(frontend)/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Shumiland</h1>
|
||||
<p>Coming soon.</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
12
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
12
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { NotFoundPage } from '@payloadcms/next/views'
|
||||
import configPromise from '@payload-config'
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>
|
||||
}
|
||||
|
||||
export default async function NotFound({ params, searchParams }: Args) {
|
||||
return NotFoundPage({ config: configPromise, importMap, params, searchParams })
|
||||
}
|
||||
17
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Metadata } from 'next'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import configPromise from '@payload-config'
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: Args): Promise<Metadata> {
|
||||
return generatePageMetadata({ config: configPromise, params, searchParams })
|
||||
}
|
||||
|
||||
export default async function Page({ params, searchParams }: Args) {
|
||||
return RootPage({ config: configPromise, importMap, params, searchParams })
|
||||
}
|
||||
6
src/app/(payload)/admin/importMap.js
Normal file
6
src/app/(payload)/admin/importMap.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// This file is auto-generated by Payload CMS.
|
||||
// Run `pnpm payload generate:importmap` to regenerate after adding components.
|
||||
// See: https://payloadcms.com/docs/configuration/overview#import-map
|
||||
|
||||
/** @type {import('payload').ImportMap} */
|
||||
export const importMap = {}
|
||||
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export const GET = REST_GET(configPromise)
|
||||
export const POST = REST_POST(configPromise)
|
||||
export const PUT = REST_PUT(configPromise)
|
||||
export const PATCH = REST_PATCH(configPromise)
|
||||
export const DELETE = REST_DELETE(configPromise)
|
||||
export const OPTIONS = REST_OPTIONS(configPromise)
|
||||
9
src/app/(payload)/layout.tsx
Normal file
9
src/app/(payload)/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Shumiland Admin',
|
||||
}
|
||||
|
||||
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
64
src/app/api/binotel/webhook/route.ts
Normal file
64
src/app/api/binotel/webhook/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { verifyHMAC, normalizePhone } from '@/lib/binotel'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const SECRET = process.env['BINOTEL_HMAC_SECRET']
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
if (!SECRET) {
|
||||
logger.error('BINOTEL_HMAC_SECRET is not configured')
|
||||
return NextResponse.json({ error: 'Service misconfigured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const body = await req.text()
|
||||
const signature = req.headers.get('x-binotel-signature') ?? ''
|
||||
|
||||
if (!verifyHMAC(body, signature, SECRET as string)) {
|
||||
logger.warn({ signature }, 'Binotel HMAC verification failed')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(body)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const externalNumber = (data as Record<string, unknown>)['externalNumber']
|
||||
if (typeof externalNumber !== 'string') {
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
const normalized = normalizePhone(externalNumber)
|
||||
if (!normalized) {
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'leads',
|
||||
where: { phone: { equals: normalized } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const lead = docs[0]
|
||||
if (lead) {
|
||||
await payload.update({
|
||||
collection: 'leads',
|
||||
id: lead.id,
|
||||
data: { lastCallAt: new Date().toISOString() },
|
||||
overrideAccess: true,
|
||||
})
|
||||
logger.info({ leadId: lead.id }, 'Updated lead lastCallAt from Binotel')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Binotel webhook DB error')
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
28
src/app/api/cron/tariffs/route.ts
Normal file
28
src/app/api/cron/tariffs/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { syncTariffs } from '@/lib/syncTariffs'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const CRON_SECRET = process.env['CRON_SECRET'] ?? ''
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a)
|
||||
const bBuf = Buffer.from(b)
|
||||
if (aBuf.length !== bBuf.length) return false
|
||||
return timingSafeEqual(aBuf, bBuf)
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const auth = req.headers.get('authorization') ?? ''
|
||||
if (!CRON_SECRET || !safeCompare(auth, `Bearer ${CRON_SECRET}`)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncTariffs()
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Cron tariffs sync failed')
|
||||
return NextResponse.json({ error: 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
39
src/app/api/health/route.ts
Normal file
39
src/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
async function checkDb(): Promise<'ok' | 'error'> {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
await payload.db.pool.query('SELECT 1')
|
||||
return 'ok'
|
||||
} catch {
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEzy(): Promise<'ok' | 'error'> {
|
||||
const activity = process.env['EZY_ACTIVITY']
|
||||
if (!activity) return 'error'
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const t = setTimeout(() => ctrl.abort(), 5_000)
|
||||
const res = await fetch(
|
||||
`https://ezy.com.ua/ipay/default/get-partner-tariff?activity=${activity}`,
|
||||
{ method: 'POST', signal: ctrl.signal }
|
||||
)
|
||||
clearTimeout(t)
|
||||
return res.ok ? 'ok' : 'error'
|
||||
} catch {
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [db, ezy] = await Promise.all([checkDb(), checkEzy()])
|
||||
const status = db === 'ok' && ezy === 'ok' ? 'healthy' : 'degraded'
|
||||
return NextResponse.json(
|
||||
{ status, db, ezy, ts: Date.now() },
|
||||
{ status: status === 'healthy' ? 200 : 503 }
|
||||
)
|
||||
}
|
||||
87
src/app/api/leads/route.ts
Normal file
87
src/app/api/leads/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { checkRateLimit } from '@/lib/rateLimit'
|
||||
import { sendLeadAlert as sendTelegramAlert } from '@/lib/telegram'
|
||||
import { sendLeadAlert as sendEmailAlert } from '@/lib/resend'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const LeadSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
phone: z
|
||||
.string()
|
||||
.min(9)
|
||||
.max(20)
|
||||
.regex(/^[+\d\s\-().]+$/, 'Invalid phone number'),
|
||||
email: z.string().email().optional(),
|
||||
formSource: z.string().min(1).max(100),
|
||||
utmSource: z.string().max(200).optional(),
|
||||
utmMedium: z.string().max(200).optional(),
|
||||
utmCampaign: z.string().max(200).optional(),
|
||||
utmContent: z.string().max(200).optional(),
|
||||
utmTerm: z.string().max(200).optional(),
|
||||
gclid: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
logger.warn({ ip }, 'Rate limit exceeded for /api/leads')
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = LeadSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.flatten() },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
await payload.create({
|
||||
collection: 'leads',
|
||||
data: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
formSource: data.formSource,
|
||||
utmSource: data.utmSource,
|
||||
utmMedium: data.utmMedium,
|
||||
utmCampaign: data.utmCampaign,
|
||||
utmContent: data.utmContent,
|
||||
utmTerm: data.utmTerm,
|
||||
gclid: data.gclid,
|
||||
status: 'new',
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save lead to DB')
|
||||
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
||||
}
|
||||
|
||||
const alertData = {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
formSource: data.formSource,
|
||||
utmSource: data.utmSource,
|
||||
}
|
||||
|
||||
void Promise.allSettled([sendTelegramAlert(alertData), sendEmailAlert(alertData)])
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 })
|
||||
}
|
||||
58
src/app/api/revalidate/route.ts
Normal file
58
src/app/api/revalidate/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const SECRET = process.env['REVALIDATE_SECRET'] ?? ''
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a)
|
||||
const bBuf = Buffer.from(b)
|
||||
if (aBuf.length !== bBuf.length) return false
|
||||
return timingSafeEqual(aBuf, bBuf)
|
||||
}
|
||||
|
||||
const RevalidateSchema = z.object({
|
||||
slug: z.string().max(200).optional(),
|
||||
collection: z.string().max(100).optional(),
|
||||
global: z.string().max(100).optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const auth = req.headers.get('authorization') ?? ''
|
||||
if (!SECRET || !safeCompare(auth, `Bearer ${SECRET}`)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = RevalidateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { slug, collection, global: globalSlug } = parsed.data
|
||||
|
||||
try {
|
||||
if (globalSlug) {
|
||||
revalidatePath('/', 'layout')
|
||||
logger.info({ global: globalSlug }, 'Revalidated layout for global change')
|
||||
} else if (slug) {
|
||||
revalidatePath(`/${slug}`)
|
||||
logger.info({ slug, collection }, 'Revalidated path')
|
||||
} else if (collection) {
|
||||
revalidatePath('/', 'layout')
|
||||
logger.info({ collection }, 'Revalidated layout for collection change')
|
||||
}
|
||||
return NextResponse.json({ revalidated: true })
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Revalidation failed')
|
||||
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
28
src/app/api/tariffs/sync/route.ts
Normal file
28
src/app/api/tariffs/sync/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { syncTariffs } from '@/lib/syncTariffs'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const SECRET = process.env['SYNC_SECRET'] ?? ''
|
||||
|
||||
function safeCompare(a: string, b: string): boolean {
|
||||
const aBuf = Buffer.from(a)
|
||||
const bBuf = Buffer.from(b)
|
||||
if (aBuf.length !== bBuf.length) return false
|
||||
return timingSafeEqual(aBuf, bBuf)
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const auth = req.headers.get('authorization') ?? ''
|
||||
if (!SECRET || !safeCompare(auth, `Bearer ${SECRET}`)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncTariffs()
|
||||
return NextResponse.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Tariffs sync failed')
|
||||
return NextResponse.json({ error: 'Sync failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
78
src/app/api/tickets/checkout/route.ts
Normal file
78
src/app/api/tickets/checkout/route.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { createPayment } from '@/lib/ezy'
|
||||
import { sendOrderAlert } from '@/lib/telegram'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const CheckoutSchema = z.object({
|
||||
email: z.string().email(),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
tariff: z.string().min(1),
|
||||
count: z
|
||||
.string()
|
||||
.regex(/^\d+$/)
|
||||
.transform(Number)
|
||||
.refine((n) => n >= 1 && n <= 10),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = CheckoutSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', details: parsed.error.flatten() },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
|
||||
const { email, items } = parsed.data
|
||||
|
||||
const ezyItems = items.map((i) => ({ tariff: i.tariff, count: String(i.count) }))
|
||||
|
||||
let monobankUrl: string
|
||||
try {
|
||||
const result = await createPayment(email, ezyItems)
|
||||
monobankUrl = result.url
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'ezy createPayment failed')
|
||||
return NextResponse.json({ error: 'Payment service unavailable' }, { status: 502 })
|
||||
}
|
||||
|
||||
const orderItems = items.map((i) => ({ tariffId: i.tariff, count: i.count }))
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
await payload.create({
|
||||
collection: 'orders',
|
||||
data: {
|
||||
email,
|
||||
items: orderItems,
|
||||
amount: 0,
|
||||
monobankUrl,
|
||||
status: 'redirected_to_payment',
|
||||
ezyActivity: process.env['EZY_ACTIVITY'] ?? '',
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save order — continuing with redirect')
|
||||
}
|
||||
|
||||
void sendOrderAlert({ email, items: orderItems, amount: 0 })
|
||||
|
||||
return NextResponse.json({ url: monobankUrl })
|
||||
}
|
||||
76
src/app/api/tickets/tariffs/route.ts
Normal file
76
src/app/api/tickets/tariffs/route.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { getTariffs } from '@/lib/ezy'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
try {
|
||||
const ezyTariffs = await getTariffs()
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const { docs: dbTariffs } = await payload.find({
|
||||
collection: 'tariffs',
|
||||
where: { visible: { equals: true } },
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const merged = ezyTariffs
|
||||
.map((t) => {
|
||||
const dbRecord = dbTariffs.find((d) => d.ezy_id === t.id)
|
||||
if (!dbRecord) return null
|
||||
return {
|
||||
id: t.id,
|
||||
name: dbRecord.display_name ?? t.name,
|
||||
price: t.price,
|
||||
categoryTag: dbRecord.category_tag,
|
||||
description: dbRecord.description,
|
||||
image: dbRecord.image,
|
||||
icon: dbRecord.icon,
|
||||
sort: dbRecord.sort ?? 0,
|
||||
}
|
||||
})
|
||||
.filter(<T>(v: T | null): v is T => v !== null)
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
|
||||
return NextResponse.json({ tariffs: merged })
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to get tariffs from ezy — trying DB fallback')
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { docs } = await payload.find({
|
||||
collection: 'tariffs',
|
||||
where: { visible: { equals: true } },
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
if (docs.length === 0) {
|
||||
return NextResponse.json({ error: 'Tariffs unavailable' }, { status: 503 })
|
||||
}
|
||||
|
||||
const fallback = docs
|
||||
.map((d) => ({
|
||||
id: d.ezy_id,
|
||||
name: d.display_name ?? d.last_synced_name,
|
||||
price: d.last_synced_price,
|
||||
categoryTag: d.category_tag,
|
||||
description: d.description,
|
||||
image: d.image,
|
||||
icon: d.icon,
|
||||
sort: d.sort ?? 0,
|
||||
stale: true,
|
||||
}))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
|
||||
return NextResponse.json({ tariffs: fallback, warning: 'Prices may be outdated' })
|
||||
} catch (dbErr) {
|
||||
logger.error({ dbErr }, 'DB fallback also failed')
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/app/globals.css
Normal file
17
src/app/globals.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
/* Brand colours — to be defined in the Figma-to-code phase */
|
||||
/* --color-primary: ; */
|
||||
/* --color-primary-foreground: ; */
|
||||
/* --color-secondary: ; */
|
||||
/* --color-secondary-foreground: ; */
|
||||
/* --color-accent: ; */
|
||||
/* --color-accent-foreground: ; */
|
||||
/* --color-background: ; */
|
||||
/* --color-foreground: ; */
|
||||
/* --color-muted: ; */
|
||||
/* --color-muted-foreground: ; */
|
||||
/* --color-border: ; */
|
||||
/* --color-destructive: ; */
|
||||
}
|
||||
14
src/app/layout.tsx
Normal file
14
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Shumiland',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="uk">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
0
src/blocks/.gitkeep
Normal file
0
src/blocks/.gitkeep
Normal file
21
src/blocks/CTA.ts
Normal file
21
src/blocks/CTA.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const CTABlock: Block = {
|
||||
slug: 'cta',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'subtitle', type: 'text' },
|
||||
{ name: 'ctaLabel', type: 'text', required: true },
|
||||
{ name: 'ctaHref', type: 'text', required: true },
|
||||
{
|
||||
name: 'variant',
|
||||
type: 'select',
|
||||
defaultValue: 'primary',
|
||||
options: [
|
||||
{ label: 'Primary', value: 'primary' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
17
src/blocks/Features.ts
Normal file
17
src/blocks/Features.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const FeaturesBlock: Block = {
|
||||
slug: 'features',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'icon', type: 'text' },
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'description', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
16
src/blocks/Gallery.ts
Normal file
16
src/blocks/Gallery.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const GalleryBlock: Block = {
|
||||
slug: 'gallery',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{
|
||||
name: 'images',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
|
||||
{ name: 'caption', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
22
src/blocks/Hero.ts
Normal file
22
src/blocks/Hero.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const HeroBlock: Block = {
|
||||
slug: 'hero',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'subtitle', type: 'textarea' },
|
||||
{ name: 'ctaLabel', type: 'text' },
|
||||
{ name: 'ctaHref', type: 'text' },
|
||||
{
|
||||
name: 'backgroundType',
|
||||
type: 'select',
|
||||
defaultValue: 'image',
|
||||
options: [
|
||||
{ label: 'Image', value: 'image' },
|
||||
{ label: 'Video', value: 'video' },
|
||||
],
|
||||
},
|
||||
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'backgroundVideoUrl', type: 'text' },
|
||||
],
|
||||
}
|
||||
10
src/blocks/ImageBlock.ts
Normal file
10
src/blocks/ImageBlock.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const ImageBlock: Block = {
|
||||
slug: 'imageBlock',
|
||||
fields: [
|
||||
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
|
||||
{ name: 'caption', type: 'text' },
|
||||
{ name: 'fullWidth', type: 'checkbox', defaultValue: false },
|
||||
],
|
||||
}
|
||||
23
src/blocks/LeadForm.ts
Normal file
23
src/blocks/LeadForm.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const LeadFormBlock: Block = {
|
||||
slug: 'leadForm',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'subtitle', type: 'text' },
|
||||
{
|
||||
name: 'formSource',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Ідентифікатор форми (для аналітики)',
|
||||
},
|
||||
{ name: 'showPhone', type: 'checkbox', defaultValue: true },
|
||||
{ name: 'showEmail', type: 'checkbox', defaultValue: false },
|
||||
{ name: 'ctaLabel', type: 'text', defaultValue: 'Відправити' },
|
||||
{
|
||||
name: 'successMessage',
|
||||
type: 'text',
|
||||
defaultValue: "Дякуємо! Ми зв'яжемося з вами найближчим часом.",
|
||||
},
|
||||
],
|
||||
}
|
||||
19
src/blocks/LocationsTeaser.ts
Normal file
19
src/blocks/LocationsTeaser.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const LocationsTeaserBlock: Block = {
|
||||
slug: 'locationsTeaser',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{
|
||||
name: 'locations',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
{ name: 'image', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'href', type: 'text' },
|
||||
{ name: 'ctaLabel', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
9
src/blocks/NewsBlock.ts
Normal file
9
src/blocks/NewsBlock.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const NewsBlock: Block = {
|
||||
slug: 'newsBlock',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', defaultValue: 'Новини' },
|
||||
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
|
||||
],
|
||||
}
|
||||
10
src/blocks/NewsletterForm.ts
Normal file
10
src/blocks/NewsletterForm.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const NewsletterFormBlock: Block = {
|
||||
slug: 'newsletterForm',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'subtitle', type: 'text' },
|
||||
{ name: 'ctaLabel', type: 'text', defaultValue: 'Підписатися' },
|
||||
],
|
||||
}
|
||||
11
src/blocks/PricingBlock.ts
Normal file
11
src/blocks/PricingBlock.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const PricingBlock: Block = {
|
||||
slug: 'pricingBlock',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'subtitle', type: 'text' },
|
||||
{ name: 'showOnlyVisible', type: 'checkbox', defaultValue: true },
|
||||
{ name: 'ctaLabel', type: 'text', defaultValue: 'Купити квиток' },
|
||||
],
|
||||
}
|
||||
7
src/blocks/RichText.ts
Normal file
7
src/blocks/RichText.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { Block } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const RichTextBlock: Block = {
|
||||
slug: 'richText',
|
||||
fields: [{ name: 'content', type: 'richText', required: true, editor: lexicalEditor({}) }],
|
||||
}
|
||||
10
src/blocks/VideoBlock.ts
Normal file
10
src/blocks/VideoBlock.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Block } from 'payload'
|
||||
|
||||
export const VideoBlock: Block = {
|
||||
slug: 'videoBlock',
|
||||
fields: [
|
||||
{ name: 'videoUrl', type: 'text', required: true, label: 'YouTube або Vimeo URL' },
|
||||
{ name: 'caption', type: 'text' },
|
||||
{ name: 'autoplay', type: 'checkbox', defaultValue: false },
|
||||
],
|
||||
}
|
||||
0
src/collections/.gitkeep
Normal file
0
src/collections/.gitkeep
Normal file
55
src/collections/BlogPosts.ts
Normal file
55
src/collections/BlogPosts.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { isAuthenticatedOrPublished } from '@/access/isAuthenticatedOrPublished'
|
||||
import { slugifyBeforeChange } from '@/hooks/slugify'
|
||||
import { revalidateAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const BlogPosts: CollectionConfig = {
|
||||
slug: 'blog-posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'status', 'publishedAt', 'updatedAt'],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
access: {
|
||||
read: isAuthenticatedOrPublished,
|
||||
create: isAdminOrEditor,
|
||||
update: isAdminOrEditor,
|
||||
delete: isAdminOrEditor,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [slugifyBeforeChange],
|
||||
afterChange: [revalidateAfterChange],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: { position: 'sidebar', date: { pickerAppearance: 'dayAndTime' } },
|
||||
},
|
||||
{ name: 'hero', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'body', type: 'richText', editor: lexicalEditor({}) },
|
||||
{ name: 'excerpt', type: 'textarea' },
|
||||
{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
|
||||
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
],
|
||||
}
|
||||
17
src/collections/Categories.ts
Normal file
17
src/collections/Categories.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
admin: { useAsTitle: 'name' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdminOrEditor,
|
||||
update: isAdminOrEditor,
|
||||
delete: isAdminOrEditor,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'slug', type: 'text', unique: true, index: true },
|
||||
],
|
||||
}
|
||||
47
src/collections/Leads.ts
Normal file
47
src/collections/Leads.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdmin } from '@/access/isAdmin'
|
||||
|
||||
export const Leads: CollectionConfig = {
|
||||
slug: 'leads',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'phone', 'formSource', 'status', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'phone', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'formSource', type: 'text', required: true, label: 'Джерело форми' },
|
||||
{ name: 'utmSource', type: 'text' },
|
||||
{ name: 'utmMedium', type: 'text' },
|
||||
{ name: 'utmCampaign', type: 'text' },
|
||||
{ name: 'utmContent', type: 'text' },
|
||||
{ name: 'utmTerm', type: 'text' },
|
||||
{ name: 'gclid', type: 'text' },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'new',
|
||||
options: [
|
||||
{ label: 'New', value: 'new' },
|
||||
{ label: 'Contacted', value: 'contacted' },
|
||||
{ label: 'Qualified', value: 'qualified' },
|
||||
{ label: 'Closed', value: 'closed' },
|
||||
],
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{ name: 'notes', type: 'textarea' },
|
||||
{
|
||||
name: 'lastCallAt',
|
||||
type: 'date',
|
||||
label: 'Last Call At',
|
||||
admin: { readOnly: true, date: { pickerAppearance: 'dayAndTime' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
17
src/collections/Media.ts
Normal file
17
src/collections/Media.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: true,
|
||||
admin: {
|
||||
useAsTitle: 'alt',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdminOrEditor,
|
||||
update: isAdminOrEditor,
|
||||
delete: isAdminOrEditor,
|
||||
},
|
||||
fields: [{ name: 'alt', type: 'text', required: true, label: 'Alt text' }],
|
||||
}
|
||||
37
src/collections/Orders.ts
Normal file
37
src/collections/Orders.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdmin } from '@/access/isAdmin'
|
||||
|
||||
export const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'amount', 'status', 'createdAt'],
|
||||
},
|
||||
access: {
|
||||
read: isAdmin,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'email', type: 'email', required: true },
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'tariffId', type: 'text', required: true },
|
||||
{ name: 'count', type: 'number', required: true, min: 1 },
|
||||
],
|
||||
},
|
||||
{ name: 'amount', type: 'number', required: true },
|
||||
{ name: 'monobankUrl', type: 'text', required: true, admin: { readOnly: true } },
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'redirected_to_payment',
|
||||
options: [{ label: 'Redirected to payment', value: 'redirected_to_payment' }],
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
{ name: 'ezyActivity', type: 'text', label: 'ezy Activity ID', admin: { readOnly: true } },
|
||||
],
|
||||
}
|
||||
82
src/collections/Pages.ts
Normal file
82
src/collections/Pages.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { isAuthenticatedOrPublished } from '@/access/isAuthenticatedOrPublished'
|
||||
import { slugifyBeforeChange } from '@/hooks/slugify'
|
||||
import { revalidateAfterChange } from '@/hooks/revalidatePath'
|
||||
import { HeroBlock } from '@/blocks/Hero'
|
||||
import { LocationsTeaserBlock } from '@/blocks/LocationsTeaser'
|
||||
import { FeaturesBlock } from '@/blocks/Features'
|
||||
import { NewsBlock } from '@/blocks/NewsBlock'
|
||||
import { NewsletterFormBlock } from '@/blocks/NewsletterForm'
|
||||
import { GalleryBlock } from '@/blocks/Gallery'
|
||||
import { RichTextBlock } from '@/blocks/RichText'
|
||||
import { ImageBlock } from '@/blocks/ImageBlock'
|
||||
import { VideoBlock } from '@/blocks/VideoBlock'
|
||||
import { LeadFormBlock } from '@/blocks/LeadForm'
|
||||
import { PricingBlock } from '@/blocks/PricingBlock'
|
||||
import { CTABlock } from '@/blocks/CTA'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'status', 'updatedAt'],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
access: {
|
||||
read: isAuthenticatedOrPublished,
|
||||
create: isAdminOrEditor,
|
||||
update: isAdminOrEditor,
|
||||
delete: isAdminOrEditor,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [slugifyBeforeChange],
|
||||
afterChange: [revalidateAfterChange],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: { readOnly: true, position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
admin: { position: 'sidebar' },
|
||||
},
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [
|
||||
HeroBlock,
|
||||
LocationsTeaserBlock,
|
||||
FeaturesBlock,
|
||||
NewsBlock,
|
||||
NewsletterFormBlock,
|
||||
GalleryBlock,
|
||||
RichTextBlock,
|
||||
ImageBlock,
|
||||
VideoBlock,
|
||||
LeadFormBlock,
|
||||
PricingBlock,
|
||||
CTABlock,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
17
src/collections/Tags.ts
Normal file
17
src/collections/Tags.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: 'tags',
|
||||
admin: { useAsTitle: 'name' },
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdminOrEditor,
|
||||
update: isAdminOrEditor,
|
||||
delete: isAdminOrEditor,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'slug', type: 'text', unique: true, index: true },
|
||||
],
|
||||
}
|
||||
78
src/collections/Tariffs.ts
Normal file
78
src/collections/Tariffs.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { CollectionConfig, FieldAccess } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { isAdmin } from '@/access/isAdmin'
|
||||
|
||||
const adminFieldAccess: FieldAccess = ({ req: { user } }) => user?.role === 'admin'
|
||||
|
||||
export const Tariffs: CollectionConfig = {
|
||||
slug: 'tariffs',
|
||||
admin: {
|
||||
useAsTitle: 'last_synced_name',
|
||||
defaultColumns: [
|
||||
'last_synced_name',
|
||||
'display_name',
|
||||
'category_tag',
|
||||
'last_synced_price',
|
||||
'visible',
|
||||
],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'ezy_id',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Встановлюється автоматично при синхронізації з ezy API',
|
||||
},
|
||||
access: { update: adminFieldAccess },
|
||||
},
|
||||
{ name: 'display_name', type: 'text', label: 'Назва для сайту (перевизначення)' },
|
||||
{ name: 'description', type: 'richText', editor: lexicalEditor({}) },
|
||||
{ name: 'image', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'icon', type: 'text', label: 'Emoji або назва іконки' },
|
||||
{
|
||||
name: 'category_tag',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Dyno', value: 'dyno' },
|
||||
{ label: 'Dyvolis', value: 'dyvolis' },
|
||||
{ label: 'Maze', value: 'maze' },
|
||||
{ label: 'Combo', value: 'combo' },
|
||||
{ label: 'Family', value: 'family' },
|
||||
],
|
||||
},
|
||||
{ name: 'sort', type: 'number', defaultValue: 0, admin: { position: 'sidebar' } },
|
||||
{ name: 'visible', type: 'checkbox', defaultValue: true, admin: { position: 'sidebar' } },
|
||||
{
|
||||
name: 'last_synced_name',
|
||||
type: 'text',
|
||||
label: 'Назва з ezy API',
|
||||
admin: { readOnly: true, description: 'Оновлюється автоматично при синхронізації' },
|
||||
access: { update: adminFieldAccess },
|
||||
},
|
||||
{
|
||||
name: 'last_synced_price',
|
||||
type: 'number',
|
||||
label: 'Ціна з ezy API (грн)',
|
||||
admin: { readOnly: true },
|
||||
access: { update: adminFieldAccess },
|
||||
},
|
||||
{
|
||||
name: 'last_synced_at',
|
||||
type: 'date',
|
||||
label: 'Дата синхронізації',
|
||||
admin: { readOnly: true, date: { pickerAppearance: 'dayAndTime' } },
|
||||
access: { update: adminFieldAccess },
|
||||
},
|
||||
],
|
||||
}
|
||||
30
src/collections/Users.ts
Normal file
30
src/collections/Users.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdmin } from '@/access/isAdmin'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: isAdminOrEditor,
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'editor',
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
0
src/emails/.gitkeep
Normal file
0
src/emails/.gitkeep
Normal file
70
src/emails/LeadAlert.tsx
Normal file
70
src/emails/LeadAlert.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
|
||||
interface LeadAlertEmailProps {
|
||||
name: string
|
||||
phone: string
|
||||
email?: string
|
||||
formSource: string
|
||||
utmSource?: string
|
||||
submittedAt: string
|
||||
}
|
||||
|
||||
export function LeadAlertEmail({
|
||||
name,
|
||||
phone,
|
||||
email,
|
||||
formSource,
|
||||
utmSource,
|
||||
submittedAt,
|
||||
}: LeadAlertEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Новий лід: {name}</Preview>
|
||||
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f4f4f5' }}>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
margin: '32px auto',
|
||||
backgroundColor: '#fff',
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Heading style={{ fontSize: 20, marginBottom: 16 }}>Новий лід з сайту Shumiland</Heading>
|
||||
<Section>
|
||||
<Text>
|
||||
<strong>Ім'я:</strong> {name}
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>Телефон:</strong> {phone}
|
||||
</Text>
|
||||
{email && (
|
||||
<Text>
|
||||
<strong>Email:</strong> {email}
|
||||
</Text>
|
||||
)}
|
||||
<Text>
|
||||
<strong>Форма:</strong> {formSource}
|
||||
</Text>
|
||||
{utmSource && (
|
||||
<Text>
|
||||
<strong>UTM Source:</strong> {utmSource}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={{ color: '#6b7280', fontSize: 12 }}>Отримано: {submittedAt}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
0
src/globals/.gitkeep
Normal file
0
src/globals/.gitkeep
Normal file
15
src/globals/CheckoutPage.ts
Normal file
15
src/globals/CheckoutPage.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const CheckoutPage: GlobalConfig = {
|
||||
slug: 'checkout-page',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'instructions', type: 'richText', editor: lexicalEditor({}) },
|
||||
{ name: 'terms', type: 'richText', editor: lexicalEditor({}) },
|
||||
],
|
||||
}
|
||||
48
src/globals/Footer.ts
Normal file
48
src/globals/Footer.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const Footer: GlobalConfig = {
|
||||
slug: 'footer',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{ name: 'logo', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'logoAlt', type: 'text' },
|
||||
{
|
||||
name: 'navLinks',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text' },
|
||||
{ name: 'href', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'phone', type: 'text' },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'address', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'socials',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'platform',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Instagram', value: 'instagram' },
|
||||
{ label: 'Facebook', value: 'facebook' },
|
||||
{ label: 'YouTube', value: 'youtube' },
|
||||
{ label: 'TikTok', value: 'tiktok' },
|
||||
],
|
||||
},
|
||||
{ name: 'url', type: 'text' },
|
||||
],
|
||||
},
|
||||
{ name: 'copyrightText', type: 'text' },
|
||||
],
|
||||
}
|
||||
22
src/globals/Header.ts
Normal file
22
src/globals/Header.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{ name: 'logo', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'logoAlt', type: 'text' },
|
||||
{
|
||||
name: 'navLinks',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text' },
|
||||
{ name: 'href', type: 'text' },
|
||||
{ name: 'openInNewTab', type: 'checkbox', defaultValue: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
59
src/globals/HomePage.ts
Normal file
59
src/globals/HomePage.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const HomePage: GlobalConfig = {
|
||||
slug: 'home-page',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{
|
||||
name: 'hero',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'subtitle', type: 'textarea' },
|
||||
{ name: 'ctaLabel', type: 'text' },
|
||||
{ name: 'ctaHref', type: 'text' },
|
||||
{ name: 'backgroundVideo', type: 'text' },
|
||||
{ name: 'backgroundImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'locations',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'shortDesc', type: 'textarea' },
|
||||
{ name: 'image', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'href', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'features',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'icon', type: 'text' },
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'news',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'limit', type: 'number', defaultValue: 3, min: 1, max: 12 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'newsletter',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'subtitle', type: 'text' },
|
||||
{ name: 'ctaLabel', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
18
src/globals/SiteSettings.ts
Normal file
18
src/globals/SiteSettings.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: 'site-settings',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{ name: 'ga4Id', type: 'text', label: 'Google Analytics 4 Measurement ID' },
|
||||
{ name: 'binotelId', type: 'text' },
|
||||
{ name: 'telegramChatId', type: 'text' },
|
||||
{ name: 'resendFrom', type: 'email', label: 'Від кого (Resend)' },
|
||||
{ name: 'defaultMetaTitle', type: 'text' },
|
||||
{ name: 'defaultMetaDescription', type: 'textarea' },
|
||||
{ name: 'defaultOgImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
}
|
||||
16
src/globals/ThankYouPage.ts
Normal file
16
src/globals/ThankYouPage.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { isAdminOrEditor } from '@/access/isAdminOrEditor'
|
||||
import { revalidateGlobalAfterChange } from '@/hooks/revalidatePath'
|
||||
|
||||
export const ThankYouPage: GlobalConfig = {
|
||||
slug: 'thank-you-page',
|
||||
access: { read: () => true, update: isAdminOrEditor },
|
||||
hooks: { afterChange: [revalidateGlobalAfterChange] },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'message', type: 'richText', editor: lexicalEditor({}) },
|
||||
{ name: 'contactPhone', type: 'text' },
|
||||
{ name: 'contactEmail', type: 'email' },
|
||||
],
|
||||
}
|
||||
0
src/hooks/.gitkeep
Normal file
0
src/hooks/.gitkeep
Normal file
41
src/hooks/revalidatePath.ts
Normal file
41
src/hooks/revalidatePath.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { CollectionAfterChangeHook, GlobalAfterChangeHook } from 'payload'
|
||||
import pino from 'pino'
|
||||
|
||||
const logger = pino({ name: 'revalidate' })
|
||||
|
||||
const SITE_URL = process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'
|
||||
const REVALIDATE_SECRET = process.env['REVALIDATE_SECRET'] ?? ''
|
||||
|
||||
type RevalidatePayload = {
|
||||
slug?: string
|
||||
collection?: string
|
||||
global?: string
|
||||
}
|
||||
|
||||
async function sendRevalidate(body: RevalidatePayload): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${SITE_URL}/api/revalidate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${REVALIDATE_SECRET}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status, body }, 'Revalidate returned non-OK')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, body }, 'Revalidate fetch failed')
|
||||
}
|
||||
}
|
||||
|
||||
export const revalidateAfterChange: CollectionAfterChangeHook = ({ doc, collection }) => {
|
||||
void sendRevalidate({ slug: doc.slug as string | undefined, collection: collection.slug })
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateGlobalAfterChange: GlobalAfterChangeHook = ({ doc, global }) => {
|
||||
void sendRevalidate({ global: global.slug })
|
||||
return doc
|
||||
}
|
||||
26
src/hooks/slugify.ts
Normal file
26
src/hooks/slugify.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { CollectionBeforeChangeHook } from 'payload'
|
||||
import CyrillicToTranslit from 'cyrillic-to-translit-js'
|
||||
|
||||
const cyrillicToTranslit = new CyrillicToTranslit()
|
||||
|
||||
function buildSlug(source: string): string {
|
||||
const transliterated = cyrillicToTranslit.transform(source, '-')
|
||||
return transliterated
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export const slugifyBeforeChange: CollectionBeforeChangeHook = ({ data, operation }) => {
|
||||
if (operation === 'create' || !data.slug) {
|
||||
if (data.title && typeof data.title === 'string') {
|
||||
return {
|
||||
...data,
|
||||
slug: buildSlug(data.title),
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
0
src/lib/.gitkeep
Normal file
0
src/lib/.gitkeep
Normal file
19
src/lib/binotel.ts
Normal file
19
src/lib/binotel.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
export function verifyHMAC(payload: string, signature: string, secret: string): boolean {
|
||||
const expected = createHmac('sha256', secret).update(payload).digest('hex')
|
||||
const expectedBuf = Buffer.from(expected, 'utf8')
|
||||
const signatureBuf = Buffer.from(signature, 'utf8')
|
||||
if (expectedBuf.length !== signatureBuf.length) return false
|
||||
return timingSafeEqual(expectedBuf, signatureBuf)
|
||||
}
|
||||
|
||||
export function normalizePhone(input: string): string | null {
|
||||
const digits = input.replace(/\D/g, '')
|
||||
|
||||
if (digits.startsWith('380') && digits.length === 12) return '+' + digits
|
||||
if (digits.startsWith('38') && digits.length === 11) return '+' + digits
|
||||
if (digits.startsWith('0') && digits.length === 10) return '+38' + digits
|
||||
|
||||
return null
|
||||
}
|
||||
95
src/lib/ezy.ts
Normal file
95
src/lib/ezy.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { z } from 'zod'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const BASE_URL = 'https://ezy.com.ua'
|
||||
|
||||
const EzyTariffSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
price: z.number(),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export type EzyTariff = z.infer<typeof EzyTariffSchema>
|
||||
|
||||
const EzyTariffsResponseSchema = z.array(EzyTariffSchema)
|
||||
|
||||
const EzyPaymentResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
form: z.null().optional(),
|
||||
})
|
||||
|
||||
type Cache<T> = { data: T; expiresAt: number }
|
||||
let tariffsCache: Cache<EzyTariff[]> | null = null
|
||||
|
||||
async function fetchWithRetry(url: string, init: RequestInit, attempt = 0): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal })
|
||||
return res
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
if (attempt < 2) {
|
||||
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt))
|
||||
return fetchWithRetry(url, init, attempt + 1)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTariffs(): Promise<EzyTariff[]> {
|
||||
const now = Date.now()
|
||||
if (tariffsCache !== null && now < tariffsCache.expiresAt) {
|
||||
logger.debug('ezy getTariffs: cache hit')
|
||||
return tariffsCache.data
|
||||
}
|
||||
|
||||
const activity = process.env['EZY_ACTIVITY']
|
||||
if (!activity) throw new Error('EZY_ACTIVITY env var is required')
|
||||
|
||||
const url = `${BASE_URL}/ipay/default/get-partner-tariff?activity=${activity}`
|
||||
logger.info({ url }, 'ezy getTariffs: fetching')
|
||||
|
||||
const res = await fetchWithRetry(url, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`ezy getTariffs failed with status ${res.status}`)
|
||||
}
|
||||
|
||||
const json: unknown = await res.json()
|
||||
const tariffs = EzyTariffsResponseSchema.parse(json)
|
||||
|
||||
tariffsCache = { data: tariffs, expiresAt: now + 5 * 60 * 1_000 }
|
||||
logger.info({ count: tariffs.length }, 'ezy getTariffs: cached')
|
||||
|
||||
return tariffs
|
||||
}
|
||||
|
||||
export async function createPayment(
|
||||
email: string,
|
||||
items: Array<{ tariff: string; count: string }>
|
||||
): Promise<{ url: string }> {
|
||||
const partnerKey = process.env['EZY_PARTNER_KEY']
|
||||
if (!partnerKey) throw new Error('EZY_PARTNER_KEY env var is required')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('email', email)
|
||||
form.append('order', JSON.stringify(items))
|
||||
form.append('partner_key', partnerKey)
|
||||
|
||||
const url = `${BASE_URL}/ipay/pay/partner-pay`
|
||||
logger.info({ itemCount: items.length }, 'ezy createPayment: posting')
|
||||
|
||||
const res = await fetchWithRetry(url, { method: 'POST', body: form })
|
||||
if (!res.ok) {
|
||||
throw new Error(`ezy createPayment failed with status ${res.status}`)
|
||||
}
|
||||
|
||||
const json: unknown = await res.json()
|
||||
const parsed = EzyPaymentResponseSchema.parse(json)
|
||||
|
||||
logger.info('ezy createPayment: success')
|
||||
return { url: parsed.url }
|
||||
}
|
||||
6
src/lib/logger.ts
Normal file
6
src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import pino from 'pino'
|
||||
|
||||
export const logger = pino({
|
||||
name: 'shumiland',
|
||||
level: process.env['LOG_LEVEL'] ?? 'info',
|
||||
})
|
||||
34
src/lib/rateLimit.ts
Normal file
34
src/lib/rateLimit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type Bucket = {
|
||||
tokens: number
|
||||
lastRefill: number
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
const MAX_TOKENS = 5
|
||||
const REFILL_INTERVAL_MS = 15 * 60 * 1000
|
||||
const REFILL_AMOUNT = 5
|
||||
|
||||
export function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
let bucket = buckets.get(ip)
|
||||
|
||||
if (!bucket) {
|
||||
bucket = { tokens: MAX_TOKENS - 1, lastRefill: now }
|
||||
buckets.set(ip, bucket)
|
||||
return true
|
||||
}
|
||||
|
||||
const elapsed = now - bucket.lastRefill
|
||||
if (elapsed >= REFILL_INTERVAL_MS) {
|
||||
bucket.tokens = REFILL_AMOUNT
|
||||
bucket.lastRefill = now
|
||||
}
|
||||
|
||||
if (bucket.tokens <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
bucket.tokens -= 1
|
||||
return true
|
||||
}
|
||||
41
src/lib/resend.ts
Normal file
41
src/lib/resend.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Resend } from 'resend'
|
||||
import { render } from '@react-email/components'
|
||||
import { LeadAlertEmail } from '@/emails/LeadAlert'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
const resend = new Resend(process.env['RESEND_API_KEY'])
|
||||
const FROM = process.env['RESEND_FROM'] ?? 'noreply@shumiland.ua'
|
||||
const MANAGER_EMAILS = (process.env['MANAGER_EMAILS'] ?? '').split(',').filter(Boolean)
|
||||
|
||||
export type LeadAlertData = {
|
||||
name: string
|
||||
phone: string
|
||||
email?: string
|
||||
formSource: string
|
||||
utmSource?: string
|
||||
}
|
||||
|
||||
export async function sendLeadAlert(lead: LeadAlertData): Promise<void> {
|
||||
if (MANAGER_EMAILS.length === 0) {
|
||||
logger.warn('MANAGER_EMAILS not configured — skipping email alert')
|
||||
return
|
||||
}
|
||||
|
||||
const html = await render(
|
||||
LeadAlertEmail({
|
||||
...lead,
|
||||
submittedAt: new Date().toLocaleString('uk-UA', { timeZone: 'Europe/Kyiv' }),
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: FROM,
|
||||
to: MANAGER_EMAILS,
|
||||
subject: `Новий лід: ${lead.name} (${lead.formSource})`,
|
||||
html,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Resend sendLeadAlert error')
|
||||
}
|
||||
}
|
||||
84
src/lib/syncTariffs.ts
Normal file
84
src/lib/syncTariffs.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { getTariffs } from '@/lib/ezy'
|
||||
import { logger } from '@/lib/logger'
|
||||
|
||||
export type SyncResult = {
|
||||
synced: number
|
||||
created: number
|
||||
hidden: number
|
||||
}
|
||||
|
||||
export async function syncTariffs(): Promise<SyncResult> {
|
||||
const payload = await getPayload({ config })
|
||||
const ezyTariffs = await getTariffs()
|
||||
|
||||
let synced = 0
|
||||
let created = 0
|
||||
|
||||
for (const tariff of ezyTariffs) {
|
||||
const { docs } = await payload.find({
|
||||
collection: 'tariffs',
|
||||
where: { ezy_id: { equals: tariff.id } },
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const existing = docs[0]
|
||||
|
||||
if (existing) {
|
||||
await payload.update({
|
||||
collection: 'tariffs',
|
||||
id: existing.id,
|
||||
data: {
|
||||
last_synced_name: tariff.name,
|
||||
last_synced_price: tariff.price,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
})
|
||||
synced++
|
||||
} else {
|
||||
await payload.create({
|
||||
collection: 'tariffs',
|
||||
data: {
|
||||
ezy_id: tariff.id,
|
||||
last_synced_name: tariff.name,
|
||||
last_synced_price: tariff.price,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
category_tag: 'dyno',
|
||||
visible: true,
|
||||
sort: 0,
|
||||
} as never,
|
||||
overrideAccess: true,
|
||||
})
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
const ezyIds = new Set(ezyTariffs.map((t) => t.id))
|
||||
|
||||
const { docs: visibleDbTariffs } = await payload.find({
|
||||
collection: 'tariffs',
|
||||
where: { visible: { equals: true } },
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
let hidden = 0
|
||||
for (const dbTariff of visibleDbTariffs) {
|
||||
const ezyId = (dbTariff as unknown as { ezy_id?: number }).ezy_id
|
||||
if (ezyId !== undefined && !ezyIds.has(ezyId)) {
|
||||
await payload.update({
|
||||
collection: 'tariffs',
|
||||
id: dbTariff.id,
|
||||
data: { visible: false } as never,
|
||||
overrideAccess: true,
|
||||
})
|
||||
hidden++
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ synced, created, hidden }, 'Tariffs sync complete')
|
||||
return { synced, created, hidden }
|
||||
}
|
||||
64
src/lib/telegram.ts
Normal file
64
src/lib/telegram.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { logger } from '@/lib/logger'
|
||||
|
||||
const BOT_TOKEN = process.env['TELEGRAM_BOT_TOKEN'] ?? ''
|
||||
const CHAT_ID = process.env['TELEGRAM_CHAT_ID'] ?? ''
|
||||
|
||||
async function sendMessage(text: string): Promise<void> {
|
||||
if (!BOT_TOKEN || !CHAT_ID) {
|
||||
logger.warn('Telegram not configured — skipping alert')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: CHAT_ID,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.warn({ status: res.status }, 'Telegram sendMessage failed')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Telegram sendMessage error')
|
||||
}
|
||||
}
|
||||
|
||||
export type LeadAlertData = {
|
||||
name: string
|
||||
phone: string
|
||||
email?: string
|
||||
formSource: string
|
||||
utmSource?: string
|
||||
}
|
||||
|
||||
export type OrderAlertData = {
|
||||
email: string
|
||||
items: Array<{ tariffId: string; count: number }>
|
||||
amount: number
|
||||
}
|
||||
|
||||
export async function sendLeadAlert(lead: LeadAlertData): Promise<void> {
|
||||
const lines = [
|
||||
'🔔 <b>Новий лід</b>',
|
||||
`👤 ${lead.name}`,
|
||||
`📱 ${lead.phone}`,
|
||||
lead.email ? `📧 ${lead.email}` : null,
|
||||
`📋 Форма: ${lead.formSource}`,
|
||||
lead.utmSource ? `🔗 UTM: ${lead.utmSource}` : null,
|
||||
]
|
||||
await sendMessage(lines.filter(Boolean).join('\n'))
|
||||
}
|
||||
|
||||
export async function sendOrderAlert(order: OrderAlertData): Promise<void> {
|
||||
const itemList = order.items.map((i) => ` • тариф ${i.tariffId} × ${i.count}`).join('\n')
|
||||
const text = [
|
||||
'🎟 <b>Новий замовлення</b>',
|
||||
`📧 ${order.email}`,
|
||||
itemList,
|
||||
`💰 ${order.amount} грн`,
|
||||
].join('\n')
|
||||
await sendMessage(text)
|
||||
}
|
||||
21
src/lib/utm.ts
Normal file
21
src/lib/utm.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const UTM_WHITELIST = [
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'gclid',
|
||||
] as const
|
||||
|
||||
export type UtmParams = Partial<Record<(typeof UTM_WHITELIST)[number], string>>
|
||||
|
||||
export function parseQuery(searchParams: URLSearchParams): UtmParams {
|
||||
const result: UtmParams = {}
|
||||
for (const key of UTM_WHITELIST) {
|
||||
const value = searchParams.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
58
src/seed.ts
Normal file
58
src/seed.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import 'dotenv/config'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '../payload.config'
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const { totalDocs } = await payload.find({
|
||||
collection: 'users',
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
if (totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'admin@shumiland.ua',
|
||||
password: 'changeMe123!',
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
console.log('Created admin user: admin@shumiland.ua / changeMe123!')
|
||||
} else {
|
||||
console.log('Users already exist, skipping user seed.')
|
||||
}
|
||||
|
||||
const globalSlugs = [
|
||||
'home-page',
|
||||
'checkout-page',
|
||||
'thank-you-page',
|
||||
'header',
|
||||
'footer',
|
||||
'site-settings',
|
||||
] as const
|
||||
|
||||
for (const slug of globalSlugs) {
|
||||
try {
|
||||
await payload.updateGlobal({
|
||||
slug,
|
||||
data: {} as never,
|
||||
overrideAccess: true,
|
||||
})
|
||||
console.log(`Initialized global: ${slug}`)
|
||||
} catch (err) {
|
||||
console.warn(`Could not initialize global ${slug}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
5
src/types/cyrillic-to-translit-js.d.ts
vendored
Normal file
5
src/types/cyrillic-to-translit-js.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module 'cyrillic-to-translit-js' {
|
||||
export default class CyrillicToTranslit {
|
||||
transform(str: string, sep?: string): string
|
||||
}
|
||||
}
|
||||
0
tests/api/.gitkeep
Normal file
0
tests/api/.gitkeep
Normal file
127
tests/api/binotel-webhook.test.ts
Normal file
127
tests/api/binotel-webhook.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
const SECRET = 'test-hmac-secret' // matches vitest.setup.ts
|
||||
|
||||
const mockPayloadFind = vi.fn()
|
||||
const mockPayloadUpdate = vi.fn()
|
||||
const mockGetPayload = vi.fn(() => ({
|
||||
find: mockPayloadFind,
|
||||
update: mockPayloadUpdate,
|
||||
}))
|
||||
|
||||
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
|
||||
vi.mock('@payload-config', () => ({ default: {} }))
|
||||
|
||||
function makeSignature(body: string): string {
|
||||
return createHmac('sha256', SECRET).update(body).digest('hex')
|
||||
}
|
||||
|
||||
function makeRequest(body: string, signature: string): NextRequest {
|
||||
return new NextRequest('http://localhost/api/binotel/webhook', {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
'x-binotel-signature': signature,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let POST: (req: NextRequest) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockPayloadFind.mockReset()
|
||||
mockPayloadUpdate.mockReset()
|
||||
vi.stubEnv('BINOTEL_HMAC_SECRET', SECRET)
|
||||
const mod = await import('@/app/api/binotel/webhook/route')
|
||||
POST = mod.POST
|
||||
})
|
||||
|
||||
describe('POST /api/binotel/webhook', () => {
|
||||
it('returns 401 for invalid HMAC signature', async () => {
|
||||
const req = makeRequest('{"externalNumber":"0501234567"}', 'bad-signature')
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for valid signature but invalid JSON body', async () => {
|
||||
const body = 'not json'
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns ok:true when externalNumber is missing from payload', async () => {
|
||||
const body = JSON.stringify({ event: 'call' })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
const json = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(json.ok).toBe(true)
|
||||
expect(mockPayloadFind).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns ok:true when externalNumber is not a string', async () => {
|
||||
const body = JSON.stringify({ externalNumber: 12345 })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
expect((await res.json()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns ok:true when phone cannot be normalized', async () => {
|
||||
const body = JSON.stringify({ externalNumber: 'INVALID' })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
expect((await res.json()).ok).toBe(true)
|
||||
expect(mockPayloadFind).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates lead lastCallAt when matching lead is found', async () => {
|
||||
mockPayloadFind.mockResolvedValueOnce({ docs: [{ id: 'lead-42' }] })
|
||||
mockPayloadUpdate.mockResolvedValueOnce({})
|
||||
|
||||
const body = JSON.stringify({ externalNumber: '0501234567' })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockPayloadFind).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: 'leads',
|
||||
where: { phone: { equals: '+380501234567' } },
|
||||
})
|
||||
)
|
||||
expect(mockPayloadUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: 'leads',
|
||||
id: 'lead-42',
|
||||
data: expect.objectContaining({ lastCallAt: expect.any(String) }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns ok:true when no matching lead is found (no update)', async () => {
|
||||
mockPayloadFind.mockResolvedValueOnce({ docs: [] })
|
||||
|
||||
const body = JSON.stringify({ externalNumber: '0501234567' })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockPayloadUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns ok:true even when the DB call throws', async () => {
|
||||
mockPayloadFind.mockRejectedValueOnce(new Error('DB down'))
|
||||
|
||||
const body = JSON.stringify({ externalNumber: '0501234567' })
|
||||
const req = makeRequest(body, makeSignature(body))
|
||||
const res = await POST(req)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
21
tests/api/health.test.ts
Normal file
21
tests/api/health.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { GET } from '@/app/api/health/route'
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
it('returns 200 with status:healthy', async () => {
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.status).toBe('healthy')
|
||||
})
|
||||
|
||||
it('includes a numeric timestamp', async () => {
|
||||
const before = Date.now()
|
||||
const res = await GET()
|
||||
const after = Date.now()
|
||||
const { ts } = await res.json()
|
||||
expect(typeof ts).toBe('number')
|
||||
expect(ts).toBeGreaterThanOrEqual(before)
|
||||
expect(ts).toBeLessThanOrEqual(after)
|
||||
})
|
||||
})
|
||||
118
tests/api/leads.test.ts
Normal file
118
tests/api/leads.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
const mockPayloadCreate = vi.fn()
|
||||
const mockGetPayload = vi.fn(() => ({ create: mockPayloadCreate }))
|
||||
|
||||
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
|
||||
vi.mock('@payload-config', () => ({ default: {} }))
|
||||
vi.mock('@/lib/telegram', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
|
||||
vi.mock('@/lib/resend', () => ({ sendLeadAlert: vi.fn().mockResolvedValue(undefined) }))
|
||||
|
||||
const VALID_LEAD = {
|
||||
name: 'Іван Іванов',
|
||||
phone: '+380501234567',
|
||||
formSource: 'hero-form',
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown, ip = '1.2.3.4'): NextRequest {
|
||||
return new NextRequest('http://localhost/api/leads', {
|
||||
method: 'POST',
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-forwarded-for': ip,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let POST: (req: NextRequest) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockPayloadCreate.mockReset()
|
||||
// Re-import so rateLimit buckets are fresh
|
||||
const mod = await import('@/app/api/leads/route')
|
||||
POST = mod.POST
|
||||
})
|
||||
|
||||
describe('POST /api/leads', () => {
|
||||
it('returns 201 for a valid lead', async () => {
|
||||
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-1' })
|
||||
const res = await POST(makeRequest(VALID_LEAD))
|
||||
expect(res.status).toBe(201)
|
||||
expect((await res.json()).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid JSON body', async () => {
|
||||
const res = await POST(makeRequest('not-json'))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 422 when required fields are missing', async () => {
|
||||
const res = await POST(makeRequest({ phone: '123' }))
|
||||
expect(res.status).toBe(422)
|
||||
const json = await res.json()
|
||||
expect(json.error).toBe('Validation failed')
|
||||
expect(json.details).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns 422 when name is empty string', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_LEAD, name: '' }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when phone is too short', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_LEAD, phone: '12345' }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when email is present but invalid', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_LEAD, email: 'not-an-email' }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('accepts optional UTM params and email', async () => {
|
||||
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-2' })
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
...VALID_LEAD,
|
||||
email: 'test@example.com',
|
||||
utmSource: 'google',
|
||||
utmMedium: 'cpc',
|
||||
utmCampaign: 'summer',
|
||||
utmContent: 'banner',
|
||||
utmTerm: 'shoes',
|
||||
gclid: 'abc123',
|
||||
})
|
||||
)
|
||||
expect(res.status).toBe(201)
|
||||
})
|
||||
|
||||
it('returns 500 when DB create throws', async () => {
|
||||
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
|
||||
const res = await POST(makeRequest(VALID_LEAD, '9.9.9.1'))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
|
||||
it('returns 429 after 5 requests from the same IP', async () => {
|
||||
mockPayloadCreate.mockResolvedValue({ id: 'x' })
|
||||
const ip = '5.5.5.5'
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await POST(makeRequest(VALID_LEAD, ip))
|
||||
}
|
||||
const res = await POST(makeRequest(VALID_LEAD, ip))
|
||||
expect(res.status).toBe(429)
|
||||
})
|
||||
|
||||
it('uses "unknown" when x-forwarded-for header is absent', async () => {
|
||||
mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-3' })
|
||||
const req = new NextRequest('http://localhost/api/leads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(VALID_LEAD),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(201)
|
||||
})
|
||||
})
|
||||
102
tests/api/revalidate.test.ts
Normal file
102
tests/api/revalidate.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
const mockRevalidatePath = vi.fn()
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: mockRevalidatePath,
|
||||
revalidateTag: vi.fn(),
|
||||
}))
|
||||
|
||||
const SECRET = 'test-revalidate-secret' // matches vitest.setup.ts
|
||||
|
||||
function makeRequest(body: unknown, token = SECRET): NextRequest {
|
||||
return new NextRequest('http://localhost/api/revalidate', {
|
||||
method: 'POST',
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let POST: (req: NextRequest) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockRevalidatePath.mockReset()
|
||||
vi.stubEnv('REVALIDATE_SECRET', SECRET)
|
||||
const mod = await import('@/app/api/revalidate/route')
|
||||
POST = mod.POST
|
||||
})
|
||||
|
||||
describe('POST /api/revalidate', () => {
|
||||
it('returns 401 when authorization header is missing', async () => {
|
||||
const req = new NextRequest('http://localhost/api/revalidate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug: 'home' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 401 for wrong token', async () => {
|
||||
const res = await POST(makeRequest({ slug: 'home' }, 'wrong-token'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 401 when REVALIDATE_SECRET env is empty (no bypass)', async () => {
|
||||
vi.stubEnv('REVALIDATE_SECRET', '')
|
||||
vi.resetModules()
|
||||
const mod = await import('@/app/api/revalidate/route')
|
||||
const res = await mod.POST(makeRequest({ slug: 'home' }, ''))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 for invalid JSON body', async () => {
|
||||
const req = new NextRequest('http://localhost/api/revalidate', {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${SECRET}`,
|
||||
},
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('revalidates a specific path when slug is provided', async () => {
|
||||
const res = await POST(makeRequest({ slug: 'about-us' }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/about-us')
|
||||
expect((await res.json()).revalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('revalidates layout when global is provided', async () => {
|
||||
const res = await POST(makeRequest({ global: 'header' }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout')
|
||||
})
|
||||
|
||||
it('revalidates layout when collection is provided', async () => {
|
||||
const res = await POST(makeRequest({ collection: 'posts' }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith('/', 'layout')
|
||||
})
|
||||
|
||||
it('returns revalidated:true and calls no revalidation when body has none of slug/collection/global', async () => {
|
||||
const res = await POST(makeRequest({}))
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockRevalidatePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 when revalidatePath throws', async () => {
|
||||
mockRevalidatePath.mockImplementationOnce(() => {
|
||||
throw new Error('cache error')
|
||||
})
|
||||
const res = await POST(makeRequest({ slug: 'boom' }))
|
||||
expect(res.status).toBe(500)
|
||||
})
|
||||
})
|
||||
116
tests/api/tickets-checkout.test.ts
Normal file
116
tests/api/tickets-checkout.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
const mockCreatePayment = vi.fn()
|
||||
const mockPayloadCreate = vi.fn()
|
||||
const mockGetPayload = vi.fn(() => ({ create: mockPayloadCreate }))
|
||||
const mockSendOrderAlert = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/lib/ezy', () => ({ createPayment: mockCreatePayment }))
|
||||
vi.mock('@/lib/telegram', () => ({ sendOrderAlert: mockSendOrderAlert }))
|
||||
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
|
||||
vi.mock('@payload-config', () => ({ default: {} }))
|
||||
|
||||
const VALID_BODY = {
|
||||
email: 'buyer@example.com',
|
||||
items: [{ tariff: 'adult-2026', count: '2' }],
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest('http://localhost/api/tickets/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let POST: (req: NextRequest) => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockCreatePayment.mockReset()
|
||||
mockPayloadCreate.mockReset()
|
||||
mockSendOrderAlert.mockReset()
|
||||
const mod = await import('@/app/api/tickets/checkout/route')
|
||||
POST = mod.POST
|
||||
})
|
||||
|
||||
describe('POST /api/tickets/checkout', () => {
|
||||
it('returns 400 for invalid JSON body', async () => {
|
||||
const req = new NextRequest('http://localhost/api/tickets/checkout', {
|
||||
method: 'POST',
|
||||
body: 'not-json',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
const res = await POST(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 422 when email is missing', async () => {
|
||||
const res = await POST(makeRequest({ items: VALID_BODY.items }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when email is invalid', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, email: 'not-email' }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when items array is empty', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, items: [] }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when count is not a numeric string', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: 'abc' }] }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when count is 0 (below minimum)', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: '0' }] }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 422 when count exceeds 10', async () => {
|
||||
const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'x', count: '11' }] }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('returns 502 when ezy createPayment fails', async () => {
|
||||
mockCreatePayment.mockRejectedValueOnce(new Error('ezy down'))
|
||||
const res = await POST(makeRequest(VALID_BODY))
|
||||
expect(res.status).toBe(502)
|
||||
})
|
||||
|
||||
it('returns 200 with payment URL on success', async () => {
|
||||
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/abc' })
|
||||
mockPayloadCreate.mockResolvedValueOnce({ id: 'order-1' })
|
||||
const res = await POST(makeRequest(VALID_BODY))
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.url).toBe('https://pay.ezy.com/abc')
|
||||
})
|
||||
|
||||
it('returns 200 even when DB order creation fails', async () => {
|
||||
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/xyz' })
|
||||
mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
|
||||
const res = await POST(makeRequest(VALID_BODY))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).url).toBe('https://pay.ezy.com/xyz')
|
||||
})
|
||||
|
||||
it('accepts items array with multiple entries', async () => {
|
||||
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/multi' })
|
||||
mockPayloadCreate.mockResolvedValueOnce({})
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
email: 'a@b.com',
|
||||
items: [
|
||||
{ tariff: 'adult', count: '2' },
|
||||
{ tariff: 'child', count: '1' },
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
122
tests/api/tickets-tariffs.test.ts
Normal file
122
tests/api/tickets-tariffs.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockGetTariffs = vi.fn()
|
||||
const mockPayloadFind = vi.fn()
|
||||
const mockGetPayload = vi.fn(() => ({ find: mockPayloadFind }))
|
||||
|
||||
vi.mock('@/lib/ezy', () => ({ getTariffs: mockGetTariffs }))
|
||||
vi.mock('payload', () => ({ getPayload: mockGetPayload }))
|
||||
vi.mock('@payload-config', () => ({ default: {} }))
|
||||
|
||||
const ezyTariffs = [
|
||||
{ id: 1, name: 'Дорослий', price: 250 },
|
||||
{ id: 2, name: 'Дитячий', price: 150 },
|
||||
]
|
||||
|
||||
const dbTariffs = [
|
||||
{ ezy_id: 1, display_name: 'Дорослий (UA)', category_tag: 'adult', sort: 0 },
|
||||
{ ezy_id: 2, display_name: 'Дитячий (UA)', category_tag: 'child', sort: 1 },
|
||||
]
|
||||
|
||||
let GET: () => Promise<Response>
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
mockGetTariffs.mockReset()
|
||||
mockPayloadFind.mockReset()
|
||||
const mod = await import('@/app/api/tickets/tariffs/route')
|
||||
GET = mod.GET
|
||||
})
|
||||
|
||||
describe('GET /api/tickets/tariffs', () => {
|
||||
it('returns merged tariffs sorted by sort field', async () => {
|
||||
mockGetTariffs.mockResolvedValueOnce(ezyTariffs)
|
||||
mockPayloadFind.mockResolvedValueOnce({ docs: dbTariffs })
|
||||
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(200)
|
||||
const { tariffs } = await res.json()
|
||||
expect(tariffs).toHaveLength(2)
|
||||
expect(tariffs[0].id).toBe(1)
|
||||
expect(tariffs[0].name).toBe('Дорослий (UA)')
|
||||
expect(tariffs[0].price).toBe(250)
|
||||
})
|
||||
|
||||
it('filters out ezy tariffs with no matching DB record', async () => {
|
||||
mockGetTariffs.mockResolvedValueOnce([...ezyTariffs, { id: 99, name: 'Unknown', price: 0 }])
|
||||
mockPayloadFind.mockResolvedValueOnce({ docs: dbTariffs })
|
||||
|
||||
const res = await GET()
|
||||
const { tariffs } = await res.json()
|
||||
expect(tariffs).toHaveLength(2)
|
||||
expect(tariffs.map((t: { id: number }) => t.id)).not.toContain(99)
|
||||
})
|
||||
|
||||
it('uses db display_name over ezy name', async () => {
|
||||
mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'EZY Name', price: 300 }])
|
||||
mockPayloadFind.mockResolvedValueOnce({
|
||||
docs: [{ ezy_id: 1, display_name: 'DB Name', sort: 0 }],
|
||||
})
|
||||
|
||||
const res = await GET()
|
||||
const { tariffs } = await res.json()
|
||||
expect(tariffs[0].name).toBe('DB Name')
|
||||
})
|
||||
|
||||
it('falls back to DB tariffs when ezy throws, with warning', async () => {
|
||||
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
|
||||
mockPayloadFind.mockResolvedValueOnce({
|
||||
docs: [
|
||||
{
|
||||
ezy_id: 1,
|
||||
display_name: 'Adult',
|
||||
last_synced_price: 250,
|
||||
last_synced_name: 'Adult',
|
||||
sort: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json.warning).toMatch(/outdated/i)
|
||||
expect(json.tariffs[0].stale).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 503 when ezy fails and DB is empty', async () => {
|
||||
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
|
||||
mockPayloadFind.mockResolvedValueOnce({ docs: [] })
|
||||
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('returns 503 when both ezy and DB fail', async () => {
|
||||
mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
|
||||
mockPayloadFind.mockRejectedValueOnce(new Error('DB down'))
|
||||
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(503)
|
||||
const json = await res.json()
|
||||
expect(json.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns tariffs sorted in ascending sort order', async () => {
|
||||
mockGetTariffs.mockResolvedValueOnce([
|
||||
{ id: 2, name: 'B', price: 100 },
|
||||
{ id: 1, name: 'A', price: 200 },
|
||||
])
|
||||
mockPayloadFind.mockResolvedValueOnce({
|
||||
docs: [
|
||||
{ ezy_id: 1, display_name: 'A', sort: 0 },
|
||||
{ ezy_id: 2, display_name: 'B', sort: 5 },
|
||||
],
|
||||
})
|
||||
|
||||
const res = await GET()
|
||||
const { tariffs } = await res.json()
|
||||
expect(tariffs[0].id).toBe(1)
|
||||
expect(tariffs[1].id).toBe(2)
|
||||
})
|
||||
})
|
||||
166
tests/helpers/github-safe.test.js
Normal file
166
tests/helpers/github-safe.test.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Tests for .claude/helpers/github-safe.js
|
||||
*
|
||||
* github-safe.js is an ES module, so we test it by running it as a subprocess
|
||||
* via child_process. This mirrors real usage and avoids ESM/CJS interop issues.
|
||||
*
|
||||
* Run: node --test tests/helpers/github-safe.test.js
|
||||
*
|
||||
* Coverage gaps addressed:
|
||||
* - Exit 1 when fewer than 2 args provided (usage guard)
|
||||
* - Exit 1 for commands not in ALLOWED_COMMANDS
|
||||
* - Body written to temp file for issue/pr comment/create with --body
|
||||
* - Positional body for "issue comment <number> <body>" form
|
||||
* - No temp file created when no body is present
|
||||
* - Shell metacharacters in body don't cause injection (they go through temp file)
|
||||
* - Temp file is cleaned up even when gh exits non-zero
|
||||
* - Non-body commands forwarded directly (no temp file)
|
||||
*
|
||||
* NOTE: Tests that need `gh` stub it with a simple Node script so the suite
|
||||
* runs without GitHub credentials.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawnSync, execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '../../.claude/helpers/github-safe.js')
|
||||
|
||||
// Spawn github-safe.js with a fake $PATH that puts a stub `gh` first
|
||||
function run(args, { ghStub = null, env = {} } = {}) {
|
||||
let binDir = null
|
||||
if (ghStub) {
|
||||
binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-stub-'))
|
||||
const stub = path.join(binDir, 'gh')
|
||||
fs.writeFileSync(stub, ghStub, { mode: 0o755 })
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', SCRIPT, ...args], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
...(binDir ? { PATH: `${binDir}:${process.env.PATH}` } : {}),
|
||||
...env,
|
||||
},
|
||||
})
|
||||
|
||||
if (binDir) fs.rmSync(binDir, { recursive: true, force: true })
|
||||
return result
|
||||
}
|
||||
|
||||
// A stub gh that just echos its args as JSON to stdout and exits 0
|
||||
const ECHO_STUB = `#!/bin/sh\necho "$@"`
|
||||
|
||||
// A stub gh that exits 1 (simulates gh failure)
|
||||
const FAIL_STUB = `#!/bin/sh\nexit 1`
|
||||
|
||||
describe('github-safe.js', () => {
|
||||
// ── Usage guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('usage guard', () => {
|
||||
it('exits 1 when no args provided', () => {
|
||||
const r = run([])
|
||||
assert.equal(r.status, 1, 'expected exit code 1')
|
||||
})
|
||||
|
||||
it('exits 1 when only one arg provided', () => {
|
||||
const r = run(['issue'])
|
||||
assert.equal(r.status, 1)
|
||||
})
|
||||
|
||||
it('prints usage text when no args', () => {
|
||||
const r = run([])
|
||||
assert.ok(r.stdout.includes('Usage'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── ALLOWED_COMMANDS allowlist ──────────────────────────────────────────────
|
||||
|
||||
describe('command allowlist', () => {
|
||||
it('exits 1 for an unknown top-level gh command', () => {
|
||||
const r = run(['badcmd', 'list'], { ghStub: ECHO_STUB })
|
||||
assert.equal(r.status, 1)
|
||||
assert.ok(r.stderr.includes('Refusing'), `stderr: ${r.stderr}`)
|
||||
})
|
||||
|
||||
const allowed = ['issue', 'pr', 'repo', 'api', 'workflow', 'run', 'release', 'auth', 'gist']
|
||||
for (const cmd of allowed) {
|
||||
it(`allows the "${cmd}" command`, () => {
|
||||
const r = run([cmd, 'list'], { ghStub: ECHO_STUB })
|
||||
assert.notEqual(r.status, 1, `${cmd} should not be blocked`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Body handling ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('body handling — temp file routing', () => {
|
||||
it('passes --body-file for "issue comment <num> <body>" positional form', () => {
|
||||
const r = run(['issue', 'comment', '42', 'hello world'], { ghStub: ECHO_STUB })
|
||||
// gh stub echoes all args; --body-file must appear in the output
|
||||
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('passes --body-file for "issue create --title T --body B" form', () => {
|
||||
const r = run(['issue', 'create', '--title', 'My Issue', '--body', 'Description here'], {
|
||||
ghStub: ECHO_STUB,
|
||||
})
|
||||
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('passes --body-file for "pr create --title T --body B" form', () => {
|
||||
const r = run(['pr', 'create', '--title', 'My PR', '--body', 'PR body'], {
|
||||
ghStub: ECHO_STUB,
|
||||
})
|
||||
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('routes body with backticks through temp file (no shell interpretation)', () => {
|
||||
const body = 'Code: `ls -la` and more `$(whoami)`'
|
||||
const r = run(['issue', 'comment', '1', body], { ghStub: ECHO_STUB })
|
||||
// If injection were happening, the shell would expand $() — instead we see --body-file
|
||||
assert.ok(r.stdout.includes('--body-file'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('does NOT create --body-file when no body is provided', () => {
|
||||
const r = run(['issue', 'list'], { ghStub: ECHO_STUB })
|
||||
assert.ok(
|
||||
!r.stdout.includes('--body-file'),
|
||||
`stdout should not contain --body-file: ${r.stdout}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Non-body commands forwarded directly ────────────────────────────────────
|
||||
|
||||
describe('passthrough for non-body commands', () => {
|
||||
it('forwards "repo list" directly without --body-file', () => {
|
||||
const r = run(['repo', 'list'], { ghStub: ECHO_STUB })
|
||||
assert.ok(!r.stdout.includes('--body-file'))
|
||||
})
|
||||
|
||||
it('forwards "workflow run" directly', () => {
|
||||
const r = run(['workflow', 'run', 'deploy.yml'], { ghStub: ECHO_STUB })
|
||||
assert.ok(!r.stdout.includes('--body-file'))
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cleanup on failure ──────────────────────────────────────────────────────
|
||||
|
||||
describe('temp file cleanup', () => {
|
||||
it('exits cleanly even when gh fails (no leftover temp files observed via fs)', () => {
|
||||
// Capture the /tmp listing before and after — no .tmp file should remain
|
||||
const tmpBefore = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-')))
|
||||
const r = run(['issue', 'comment', '1', 'body text'], { ghStub: FAIL_STUB })
|
||||
const tmpAfter = new Set(fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith('gh-body-')))
|
||||
|
||||
const leaked = [...tmpAfter].filter((f) => !tmpBefore.has(f))
|
||||
assert.deepEqual(leaked, [], `Temp files leaked: ${leaked.join(', ')}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
254
tests/helpers/hook-handler.test.js
Normal file
254
tests/helpers/hook-handler.test.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* Tests for .claude/helpers/hook-handler.cjs
|
||||
*
|
||||
* hook-handler.cjs is run as a subprocess (as Claude Code invokes it),
|
||||
* so all tests spawn it via child_process and inspect stdout/stderr/exitCode.
|
||||
*
|
||||
* Run: node --test tests/helpers/hook-handler.test.js
|
||||
*
|
||||
* Coverage gaps addressed:
|
||||
* - pre-bash: blocks known dangerous commands, allows safe commands
|
||||
* - route: routes via router.routeTask, formats output table
|
||||
* - post-edit: outputs [OK] Edit recorded
|
||||
* - pre-task: outputs task routing info
|
||||
* - post-task: outputs [OK] Task completed
|
||||
* - session-restore: falls back gracefully when session module missing
|
||||
* - session-end: outputs [OK] Session ended (or intelligence consolidation)
|
||||
* - stats: warns when intelligence module unavailable
|
||||
* - unknown command: passes through with [OK]
|
||||
* - no command: prints usage
|
||||
* - always exits 0 (hooks must never crash Claude Code)
|
||||
* - safeRequire: silences noisy module output during require
|
||||
* - runWithTimeout: resolves null on timeout (tested via session-restore latency)
|
||||
* - stdin JSON: hook reads and parses stdin data
|
||||
* - global safety timeout: process exits within 5s even if handler hangs
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '../../.claude/helpers/hook-handler.cjs')
|
||||
|
||||
function run(args = [], { stdin = '', env = {} } = {}) {
|
||||
return spawnSync(process.execPath, [SCRIPT, ...args], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
timeout: 8000,
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Exit code guarantee ────────────────────────────────────────────────────────
|
||||
|
||||
describe('hook-handler.cjs — exit code', () => {
|
||||
const commands = [
|
||||
'route',
|
||||
'pre-bash',
|
||||
'post-edit',
|
||||
'session-restore',
|
||||
'session-end',
|
||||
'pre-task',
|
||||
'post-task',
|
||||
'stats',
|
||||
'unknown-cmd',
|
||||
'',
|
||||
]
|
||||
for (const cmd of commands) {
|
||||
it(`always exits 0 for command "${cmd || '(none)'}"`, () => {
|
||||
const r = run(cmd ? [cmd] : [])
|
||||
assert.equal(r.status, 0, `status: ${r.status}, stderr: ${r.stderr}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Usage ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no command', () => {
|
||||
it('prints usage when no command is provided', () => {
|
||||
const r = run([])
|
||||
assert.ok(r.stdout.includes('Usage'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Unknown command ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('unknown command', () => {
|
||||
it('outputs [OK] for an unrecognised command', () => {
|
||||
const r = run(['some-future-hook'])
|
||||
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── pre-bash ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('pre-bash handler', () => {
|
||||
it('blocks "rm -rf /" with exit 0 and BLOCKED output', () => {
|
||||
// Danger detected → still exits 0 (process.exit(1) is called from within
|
||||
// main() which is caught by the .catch() that always exits 0 — but
|
||||
// the actual implementation calls process.exit(1) directly inside the handler.
|
||||
// We test the stderr/stdout signal instead.
|
||||
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'rm -rf /' }) })
|
||||
assert.ok(
|
||||
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
|
||||
`Expected BLOCKED. stdout: ${r.stdout} stderr: ${r.stderr}`
|
||||
)
|
||||
})
|
||||
|
||||
it('outputs [OK] Command validated for a safe command', () => {
|
||||
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'ls -la' }) })
|
||||
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('blocks "format c:" (Windows-style destructive command)', () => {
|
||||
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: 'format c:' }) })
|
||||
assert.ok(
|
||||
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
|
||||
`stdout: ${r.stdout} stderr: ${r.stderr}`
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks the fork bomb ":(){:|:&};:"', () => {
|
||||
const r = run(['pre-bash'], { stdin: JSON.stringify({ command: ':(){:|:&};:' }) })
|
||||
assert.ok(
|
||||
r.stderr.includes('[BLOCKED]') || r.stdout.includes('[BLOCKED]'),
|
||||
`stdout: ${r.stdout} stderr: ${r.stderr}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── route handler ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('route handler', () => {
|
||||
it('outputs a recommendation table with Agent row', () => {
|
||||
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'implement a new feature' }) })
|
||||
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('outputs Confidence row', () => {
|
||||
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'write tests for the module' }) })
|
||||
assert.ok(r.stdout.includes('Confidence:'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('routes "write tests" prompt to tester agent', () => {
|
||||
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'write tests for auth module' }) })
|
||||
assert.ok(r.stdout.includes('tester'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('falls back to coder for an unknown prompt', () => {
|
||||
const r = run(['route'], { stdin: JSON.stringify({ prompt: 'xyzzy completely unknown task' }) })
|
||||
assert.ok(r.stdout.includes('coder'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('works when no prompt is provided (empty stdin)', () => {
|
||||
const r = run(['route'], { stdin: '' })
|
||||
// Should not crash — must exit 0 (already tested above) and emit some output
|
||||
assert.ok(r.stdout.length > 0, 'expected some output')
|
||||
})
|
||||
})
|
||||
|
||||
// ── post-edit handler ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('post-edit handler', () => {
|
||||
it('outputs [OK] Edit recorded', () => {
|
||||
const r = run(['post-edit'], { stdin: JSON.stringify({ file_path: 'src/index.js' }) })
|
||||
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── pre-task handler ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('pre-task handler', () => {
|
||||
it('outputs task routing info', () => {
|
||||
const r = run(['pre-task'], { stdin: JSON.stringify({ prompt: 'build the API' }) })
|
||||
assert.ok(r.stdout.includes('[OK]') || r.stdout.includes('[INFO]'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── post-task handler ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('post-task handler', () => {
|
||||
it('outputs [OK] Task completed', () => {
|
||||
const r = run(['post-task'])
|
||||
assert.ok(r.stdout.includes('[OK]'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
})
|
||||
|
||||
// ── session-restore handler ────────────────────────────────────────────────────
|
||||
|
||||
describe('session-restore handler', () => {
|
||||
it('runs without error and produces some output', () => {
|
||||
const r = run(['session-restore'])
|
||||
assert.ok(r.stdout.length > 0 || r.stderr.length > 0, 'expected some output')
|
||||
})
|
||||
})
|
||||
|
||||
// ── session-end handler ────────────────────────────────────────────────────────
|
||||
|
||||
describe('session-end handler', () => {
|
||||
it('runs without error and exits 0', () => {
|
||||
const r = run(['session-end'])
|
||||
assert.equal(r.status, 0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── stats handler ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stats handler', () => {
|
||||
it('warns when intelligence module is unavailable', () => {
|
||||
// In the test environment there's no built intelligence db — WARN is expected
|
||||
const r = run(['stats'])
|
||||
// Either warns or prints stats — both acceptable outcomes
|
||||
assert.ok(
|
||||
r.stdout.includes('[WARN]') || r.stdout.includes('patterns') || r.stdout.length > 0,
|
||||
`stdout: ${r.stdout}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── stdin JSON parsing ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('stdin JSON hook data', () => {
|
||||
it('reads tool_name (snake_case) from stdin JSON', () => {
|
||||
const r = run(['route'], {
|
||||
stdin: JSON.stringify({
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' },
|
||||
prompt: 'implement something',
|
||||
}),
|
||||
})
|
||||
// If parsed correctly, routing runs; check for the recommendation table
|
||||
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('reads toolName (camelCase) from stdin JSON', () => {
|
||||
const r = run(['route'], {
|
||||
stdin: JSON.stringify({
|
||||
toolName: 'Edit',
|
||||
toolInput: { file_path: 'a.js' },
|
||||
prompt: 'review code',
|
||||
}),
|
||||
})
|
||||
assert.ok(r.stdout.includes('Agent:'), `stdout: ${r.stdout}`)
|
||||
})
|
||||
|
||||
it('handles malformed JSON stdin gracefully (no crash)', () => {
|
||||
const r = run(['pre-bash'], { stdin: '{ invalid json' })
|
||||
assert.equal(r.status, 0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Integration: full pipeline event simulation ────────────────────────────────
|
||||
|
||||
describe('integration — simulated Claude Code hook sequence', () => {
|
||||
it('session-restore → pre-task → post-edit → post-task → session-end all exit 0', () => {
|
||||
const sequence = ['session-restore', 'pre-task', 'post-edit', 'post-task', 'session-end']
|
||||
for (const cmd of sequence) {
|
||||
const r = run([cmd], { stdin: JSON.stringify({ prompt: 'build feature X' }) })
|
||||
assert.equal(r.status, 0, `${cmd} must exit 0, got ${r.status}. stderr: ${r.stderr}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
219
tests/helpers/memory.test.js
Normal file
219
tests/helpers/memory.test.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Tests for .claude/helpers/memory.js
|
||||
*
|
||||
* Run: node --test tests/helpers/memory.test.js
|
||||
*
|
||||
* Coverage gaps addressed:
|
||||
* - loadMemory: missing file, corrupted JSON, valid file
|
||||
* - saveMemory: directory creation, JSON format
|
||||
* - commands.get: with key, without key, missing key
|
||||
* - commands.set: missing key arg, _updated timestamp, overwrite
|
||||
* - commands.delete: missing key arg, non-existent key
|
||||
* - commands.keys: filters _-prefixed meta keys
|
||||
* - commands.clear: resets to empty object
|
||||
* - Error handling: corrupted JSON masked silently
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
|
||||
// Helper to load the module with a custom CWD so MEMORY_FILE points to a tmp dir
|
||||
function loadModule(cwd) {
|
||||
// Clear require cache so MEMORY_DIR/MEMORY_FILE are re-evaluated each time
|
||||
const modPath = require.resolve('../../.claude/helpers/memory.js')
|
||||
delete require.cache[modPath]
|
||||
const origCwd = process.cwd
|
||||
process.cwd = () => cwd
|
||||
try {
|
||||
const mod = require('../../.claude/helpers/memory.js')
|
||||
return mod
|
||||
} finally {
|
||||
process.cwd = origCwd
|
||||
delete require.cache[modPath]
|
||||
}
|
||||
}
|
||||
|
||||
describe('memory.js', () => {
|
||||
let tmpDir
|
||||
let commands
|
||||
let memoryFile
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mem-test-'))
|
||||
memoryFile = path.join(tmpDir, '.claude-flow', 'data', 'memory.json')
|
||||
})
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset memory file before each test
|
||||
const dir = path.dirname(memoryFile)
|
||||
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true })
|
||||
commands = loadModule(tmpDir)
|
||||
})
|
||||
|
||||
// ── loadMemory ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('loadMemory (via commands.get)', () => {
|
||||
it('returns {} when memory file does not exist', () => {
|
||||
const result = commands.get(undefined)
|
||||
assert.deepEqual(result, {})
|
||||
})
|
||||
|
||||
it('returns {} and does not throw when file contains corrupted JSON', () => {
|
||||
const dir = path.dirname(memoryFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(memoryFile, '{ not valid json !!!')
|
||||
// Should not throw; silently returns {}
|
||||
const result = commands.get(undefined)
|
||||
assert.deepEqual(result, {})
|
||||
})
|
||||
|
||||
it('parses and returns stored memory when file is valid JSON', () => {
|
||||
const dir = path.dirname(memoryFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(memoryFile, JSON.stringify({ foo: 'bar' }, null, 2))
|
||||
const result = commands.get('foo')
|
||||
assert.equal(result, 'bar')
|
||||
})
|
||||
|
||||
// EDGE CASE: empty file (0 bytes) should not crash
|
||||
it('returns {} when file is empty', () => {
|
||||
const dir = path.dirname(memoryFile)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(memoryFile, '')
|
||||
const result = commands.get(undefined)
|
||||
assert.deepEqual(result, {})
|
||||
})
|
||||
})
|
||||
|
||||
// ── saveMemory (via commands.set) ──────────────────────────────────────────
|
||||
|
||||
describe('saveMemory (via commands.set)', () => {
|
||||
it('creates the data directory if it does not exist', () => {
|
||||
commands.set('k', 'v')
|
||||
assert.ok(fs.existsSync(path.dirname(memoryFile)))
|
||||
})
|
||||
|
||||
it('writes pretty-printed JSON to disk', () => {
|
||||
commands.set('hello', 'world')
|
||||
const raw = fs.readFileSync(memoryFile, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
assert.equal(parsed.hello, 'world')
|
||||
// Pretty-printed → contains newlines
|
||||
assert.ok(raw.includes('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
// ── commands.get ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('commands.get', () => {
|
||||
it('returns the whole memory object when no key is provided', () => {
|
||||
commands.set('a', 1)
|
||||
commands.set('b', 2)
|
||||
const result = commands.get(undefined)
|
||||
assert.equal(result.a, 1)
|
||||
assert.equal(result.b, 2)
|
||||
})
|
||||
|
||||
it('returns undefined for a key that was never set', () => {
|
||||
const result = commands.get('nonexistent')
|
||||
assert.equal(result, undefined)
|
||||
})
|
||||
|
||||
it('returns the value for an existing key', () => {
|
||||
commands.set('mykey', 'myvalue')
|
||||
const result = commands.get('mykey')
|
||||
assert.equal(result, 'myvalue')
|
||||
})
|
||||
})
|
||||
|
||||
// ── commands.set ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('commands.set', () => {
|
||||
it('returns undefined and does not write when key is missing', () => {
|
||||
const result = commands.set(undefined, 'value')
|
||||
assert.equal(result, undefined)
|
||||
assert.ok(!fs.existsSync(memoryFile))
|
||||
})
|
||||
|
||||
it('writes a _updated ISO timestamp alongside the value', () => {
|
||||
const before = Date.now()
|
||||
commands.set('ts-test', 'v')
|
||||
const after = Date.now()
|
||||
const raw = JSON.parse(fs.readFileSync(memoryFile, 'utf-8'))
|
||||
const ts = new Date(raw._updated).getTime()
|
||||
assert.ok(ts >= before && ts <= after + 100)
|
||||
})
|
||||
|
||||
it('overwrites an existing key without losing other keys', () => {
|
||||
commands.set('x', 'first')
|
||||
commands.set('y', 'other')
|
||||
commands.set('x', 'second')
|
||||
assert.equal(commands.get('x'), 'second')
|
||||
assert.equal(commands.get('y'), 'other')
|
||||
})
|
||||
})
|
||||
|
||||
// ── commands.delete ────────────────────────────────────────────────────────
|
||||
|
||||
describe('commands.delete', () => {
|
||||
it('logs error and does not write when key is missing', () => {
|
||||
const result = commands.delete(undefined)
|
||||
assert.equal(result, undefined)
|
||||
assert.ok(!fs.existsSync(memoryFile))
|
||||
})
|
||||
|
||||
it('removes an existing key from memory', () => {
|
||||
commands.set('to-remove', 'val')
|
||||
commands.delete('to-remove')
|
||||
assert.equal(commands.get('to-remove'), undefined)
|
||||
})
|
||||
|
||||
it('is a no-op and does not throw when deleting a non-existent key', () => {
|
||||
commands.set('keep', 'val')
|
||||
assert.doesNotThrow(() => commands.delete('ghost'))
|
||||
assert.equal(commands.get('keep'), 'val')
|
||||
})
|
||||
})
|
||||
|
||||
// ── commands.keys ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('commands.keys', () => {
|
||||
it('returns only user-defined keys, excluding _-prefixed meta keys', () => {
|
||||
commands.set('alpha', 1)
|
||||
commands.set('beta', 2)
|
||||
const keys = commands.keys()
|
||||
assert.ok(keys.includes('alpha'))
|
||||
assert.ok(keys.includes('beta'))
|
||||
assert.ok(!keys.includes('_updated'), '_updated must be filtered out')
|
||||
})
|
||||
|
||||
it('returns an empty array when memory is empty', () => {
|
||||
const keys = commands.keys()
|
||||
assert.deepEqual(keys, [])
|
||||
})
|
||||
})
|
||||
|
||||
// ── commands.clear ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('commands.clear', () => {
|
||||
it('replaces all memory with an empty object', () => {
|
||||
commands.set('a', 1)
|
||||
commands.set('b', 2)
|
||||
commands.clear()
|
||||
assert.deepEqual(commands.get(undefined), {})
|
||||
})
|
||||
|
||||
it('does not throw when memory is already empty', () => {
|
||||
assert.doesNotThrow(() => commands.clear())
|
||||
})
|
||||
})
|
||||
})
|
||||
199
tests/helpers/router.test.js
Normal file
199
tests/helpers/router.test.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Tests for .claude/helpers/router.js
|
||||
*
|
||||
* Run: node --test tests/helpers/router.test.js
|
||||
*
|
||||
* Coverage gaps addressed:
|
||||
* - routeTask: each task pattern, default fallback, case insensitivity
|
||||
* - Return shape: agent, confidence, reason fields always present
|
||||
* - Edge cases: empty string, whitespace-only, numeric string
|
||||
* - Pattern conflicts: first-match wins (no multi-match merging)
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { describe, it } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { routeTask, AGENT_CAPABILITIES, TASK_PATTERNS } = require('../../.claude/helpers/router.js')
|
||||
|
||||
describe('router.js — routeTask()', () => {
|
||||
// ── Return shape ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('return shape', () => {
|
||||
it('always returns an object with agent, confidence, and reason', () => {
|
||||
const r = routeTask('anything')
|
||||
assert.ok(typeof r.agent === 'string', 'agent must be a string')
|
||||
assert.ok(typeof r.confidence === 'number', 'confidence must be a number')
|
||||
assert.ok(typeof r.reason === 'string', 'reason must be a string')
|
||||
})
|
||||
|
||||
it('confidence is between 0 and 1', () => {
|
||||
const tasks = ['build a feature', 'write tests', 'random unknown task']
|
||||
for (const t of tasks) {
|
||||
const r = routeTask(t)
|
||||
assert.ok(r.confidence >= 0 && r.confidence <= 1, `confidence out of range for: ${t}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Pattern matching ────────────────────────────────────────────────────────
|
||||
|
||||
describe('pattern → agent routing', () => {
|
||||
// NOTE: router uses first-match-wins on TASK_PATTERNS key order.
|
||||
// Keywords like 'build', 'create', 'implement' match the coder pattern BEFORE
|
||||
// domain-specific patterns (backend-dev, frontend-dev). Tests below reflect
|
||||
// actual routing behaviour, not intuitive intent — see the "known routing quirks"
|
||||
// section below for cases where first-match hides the intended agent.
|
||||
const cases = [
|
||||
// coder pattern: 'implement|create|build|add|write code'
|
||||
['implement a login page', 'coder'],
|
||||
['create a REST endpoint', 'coder'], // 'create' → coder, not backend-dev
|
||||
['build authentication', 'coder'],
|
||||
['add a new field to the schema', 'coder'],
|
||||
['write code for the parser', 'coder'],
|
||||
|
||||
// tester pattern: 'test|spec|coverage|unit test|integration'
|
||||
['test the signup flow', 'tester'],
|
||||
['write unit tests for utils', 'tester'],
|
||||
['check coverage for auth module', 'tester'],
|
||||
['integration tests for payment API', 'tester'],
|
||||
['write a spec for the validator', 'tester'],
|
||||
|
||||
// reviewer pattern: 'review|audit|check|validate|security'
|
||||
['review the PR for security issues', 'reviewer'],
|
||||
['audit the codebase for vulnerabilities', 'reviewer'],
|
||||
['check code quality', 'reviewer'],
|
||||
['validate the configuration', 'reviewer'],
|
||||
|
||||
// researcher pattern: 'research|find|search|documentation|explore'
|
||||
['research best JWT libraries', 'researcher'],
|
||||
['find documentation for Redis', 'researcher'],
|
||||
['search for OAuth examples', 'researcher'],
|
||||
['explore alternative approaches', 'researcher'],
|
||||
|
||||
// architect pattern: 'design|architect|structure|plan'
|
||||
['design the microservice architecture', 'architect'],
|
||||
['plan the database structure', 'architect'],
|
||||
|
||||
// backend-dev pattern: 'api|endpoint|server|backend|database'
|
||||
// Only works when coder/tester/reviewer/researcher/architect keywords are absent
|
||||
['set up the database schema', 'backend-dev'],
|
||||
['the server-side middleware', 'backend-dev'], // 'server' matches backend-dev
|
||||
['REST api for payment gateway', 'backend-dev'], // 'api' without documentation/search
|
||||
|
||||
// frontend-dev pattern: 'ui|frontend|component|react|css|style'
|
||||
// Only works when patterns 1-6 don't fire first
|
||||
['style the header with CSS', 'frontend-dev'],
|
||||
['the UI layout mockup', 'frontend-dev'], // no review/research/create
|
||||
['react hooks and state', 'frontend-dev'], // 'react' not in earlier patterns
|
||||
|
||||
// devops pattern: 'deploy|docker|ci|cd|pipeline|infrastructure'
|
||||
['deploy to production', 'devops'],
|
||||
['set up Docker Compose', 'devops'], // 'set up' doesn't match coder
|
||||
['configure the CI pipeline', 'devops'],
|
||||
]
|
||||
|
||||
for (const [task, expectedAgent] of cases) {
|
||||
it(`"${task}" → ${expectedAgent}`, () => {
|
||||
const r = routeTask(task)
|
||||
assert.equal(r.agent, expectedAgent, `Expected ${expectedAgent}, got ${r.agent}`)
|
||||
assert.equal(r.confidence, 0.8)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Default fallback ────────────────────────────────────────────────────────
|
||||
|
||||
describe('default fallback', () => {
|
||||
it('routes to coder with confidence 0.5 when no pattern matches', () => {
|
||||
const r = routeTask('do something completely unknown xyz123')
|
||||
assert.equal(r.agent, 'coder')
|
||||
assert.equal(r.confidence, 0.5)
|
||||
})
|
||||
|
||||
it('handles empty string input without throwing', () => {
|
||||
assert.doesNotThrow(() => routeTask(''))
|
||||
const r = routeTask('')
|
||||
assert.equal(r.agent, 'coder')
|
||||
assert.equal(r.confidence, 0.5)
|
||||
})
|
||||
|
||||
it('handles whitespace-only input', () => {
|
||||
const r = routeTask(' ')
|
||||
assert.equal(r.agent, 'coder')
|
||||
})
|
||||
|
||||
it('handles numeric string input', () => {
|
||||
const r = routeTask('12345')
|
||||
assert.equal(r.agent, 'coder')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Case insensitivity ──────────────────────────────────────────────────────
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
it('matches uppercase task keywords', () => {
|
||||
const r = routeTask('IMPLEMENT a feature')
|
||||
assert.equal(r.agent, 'coder')
|
||||
})
|
||||
|
||||
it('matches mixed-case task keywords', () => {
|
||||
const r = routeTask('Write Tests For The Module')
|
||||
assert.equal(r.agent, 'tester')
|
||||
})
|
||||
})
|
||||
|
||||
// ── AGENT_CAPABILITIES export ───────────────────────────────────────────────
|
||||
|
||||
describe('AGENT_CAPABILITIES', () => {
|
||||
it('exports capabilities for all core agents', () => {
|
||||
const expected = [
|
||||
'coder',
|
||||
'tester',
|
||||
'reviewer',
|
||||
'researcher',
|
||||
'architect',
|
||||
'backend-dev',
|
||||
'frontend-dev',
|
||||
'devops',
|
||||
]
|
||||
for (const agent of expected) {
|
||||
assert.ok(agent in AGENT_CAPABILITIES, `Missing capabilities for: ${agent}`)
|
||||
assert.ok(Array.isArray(AGENT_CAPABILITIES[agent]))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Known routing quirks (first-match-wins causes mis-routing) ───────────────
|
||||
// These are NOT bugs in the tests — they document real mis-routing that should
|
||||
// be fixed by reordering TASK_PATTERNS or using a scoring approach.
|
||||
|
||||
describe('known routing quirks — first-match wins over domain intent', () => {
|
||||
it('"create a React component" routes to coder, not frontend-dev (create matches first)', () => {
|
||||
assert.equal(routeTask('create a React component').agent, 'coder')
|
||||
})
|
||||
|
||||
it('"build the UI dashboard" routes to coder, not frontend-dev (build matches first)', () => {
|
||||
assert.equal(routeTask('build the UI dashboard').agent, 'coder')
|
||||
})
|
||||
|
||||
it('"implement server-side authentication" routes to coder, not backend-dev', () => {
|
||||
assert.equal(routeTask('implement server-side authentication').agent, 'coder')
|
||||
})
|
||||
|
||||
it('"build the REST API" routes to coder, not backend-dev (build matches first)', () => {
|
||||
assert.equal(routeTask('build the REST API').agent, 'coder')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Integration: first-match-wins when task matches multiple patterns ────────
|
||||
|
||||
describe('first-match-wins on ambiguous tasks', () => {
|
||||
it('resolves to the first matching pattern when task fits multiple', () => {
|
||||
// "implement api" matches 'implement' (→ coder) AND 'api' (→ backend-dev)
|
||||
const r = routeTask('implement api endpoint')
|
||||
assert.equal(r.agent, 'coder') // coder wins because it is listed first
|
||||
assert.equal(r.confidence, 0.8)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue