commit c573e8bfe629d8e910049b637f708a8b6df8894f Author: root Date: Wed Aug 5 19:05:50 2026 +0300 init: scaffold проекта — schema, docker-compose, nginx, README, CI - db/schema.sql: полная PostgreSQL 16 схема (12 таблиц + view works_extended) - docker-compose.yml: db + backend + frontend + nginx - nginx/nginx.conf: HTTPS proxy, WebSocket, static uploads - .env.example: все переменные задокументированы - .gitea/workflows/deploy.yml: CI деплой-заготовка - README.md: граф задач флота агентов Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..887b1ce --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Database +DATABASE_URL=postgresql://postgres:PASSWORD@db:5432/nika_gallery +POSTGRES_PASSWORD=changeme + +# Auth +JWT_SECRET=change-me-jwt-secret +JWT_REFRESH_SECRET=change-me-refresh-secret + +# Email +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@nika-gallery.ru +SMTP_PASS=smtp-password +OWNER_EMAIL=nika@example.com + +# Telegram +TELEGRAM_BOT_TOKEN=bot:token +TELEGRAM_CHANNEL_ID=@nika_channel +OWNER_TELEGRAM_ID=123456789 + +# App +SITE_URL=https://nika-gallery.ru +PORT=4000 +NODE_ENV=production +UPLOAD_DIR=/app/uploads + +# Frontend +NEXT_PUBLIC_API_URL=https://nika-gallery.ru/api +NEXT_PUBLIC_WS_URL=wss://nika-gallery.ru/ws diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e3cd61a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,20 @@ +# Gitea Actions — деплой при push в main +# Требует: runner на prod-сервере + секреты DEPLOY_HOST, DEPLOY_KEY +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy via SSH + run: | + echo "${{ secrets.DEPLOY_KEY }}" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no \ + root@${{ secrets.DEPLOY_HOST }} \ + "cd /opt/nika-gallery && git pull && docker compose build && docker compose up -d" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3e3f43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +*.env.local +uploads/ +dist/ +.next/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3925742 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Ника — Галерея & Аукцион + +Сайт дизайнера Ники: галерея работ, аукцион, подписки, Telegram-интеграция. + +## Стек +- **Backend:** Node.js/Express (ESM), PostgreSQL 16 +- **Frontend:** Next.js 14 App Router, TypeScript, Tailwind CSS +- **Real-time:** WebSocket (ws) +- **Telegram:** grammy +- **Deploy:** Docker Compose + Nginx + Let's Encrypt + +## Структура +``` +nika-gallery/ +├── backend/ # Express API (порт 4000) +├── frontend/ # Next.js (порт 3000) +├── db/ # schema.sql + seed.sql +├── nginx/ # nginx.conf +├── scripts/ # import-channel.mjs и утилиты +└── docker-compose.yml +``` + +## Задачи флота агентов + +| # | Задача | Статус | Блокирует | +|---|--------|--------|-----------| +| 1 | БД-схема + API-контракт | ✅ schema.sql готова | — | +| 2 | Backend scaffold + Auth | pending | 1 | +| 3 | Frontend scaffold | pending | 1 | +| 4 | Галерея: CRUD + медиа | pending | 2,3 | +| 5 | Аукцион: primary/secondary + WS | pending | 4 | +| 6 | Подписки + рассылка | pending | 2,3 | +| 7 | Социальный слой | pending | 4 | +| 8 | Уведомления владельцу | pending | 5,7 | +| 9 | Telegram-бот публикации | pending | 4 | +| 10 | Импорт из TG-канала | pending | 9 | +| 11 | Docker + деплой + SSL | pending | 2,3 | + +## Быстрый старт + +```bash +cp .env.example .env +# отредактировать .env +docker compose up -d +``` + +## Переменные окружения +Смотри `.env.example` + +## Связь с оператором +- Telegram: @Ya26_bot (chat_id 1113994683) +- Агент-ноутбук: Claude Code на ноутбуке Германа diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..8032e9f --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,201 @@ +-- nika-gallery: полная схема PostgreSQL 16 +-- Создано: ops-agent 2026-08-05 + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Пользователи +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'buyer' CHECK (role IN ('owner','admin','buyer','subscriber')), + telegram_id BIGINT, + email_verified_at TIMESTAMPTZ, + refresh_token TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Категории работ +CREATE TABLE categories ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0 +); + +-- Работы +CREATE TABLE works ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + medium TEXT, -- техника: масло, акварель... + dimensions TEXT, -- размер: 60x80 см + year INT, + price NUMERIC(12,2), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft','published','sold')), + auction_mode TEXT NOT NULL DEFAULT 'none' + CHECK (auction_mode IN ('none','primary','secondary')), + telegram_message_id BIGINT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Категории работ (M2M) +CREATE TABLE work_categories ( + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + category_id INT REFERENCES categories(id) ON DELETE CASCADE, + PRIMARY KEY (work_id, category_id) +); + +-- Медиа +CREATE TABLE media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + url TEXT NOT NULL, + thumb_url TEXT, + type TEXT NOT NULL DEFAULT 'image' CHECK (type IN ('image','video')), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Аукционы +CREATE TABLE auctions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('primary','secondary')), + start_price NUMERIC(12,2) NOT NULL, + current_price NUMERIC(12,2) NOT NULL, + reserve_price NUMERIC(12,2), + min_step NUMERIC(12,2) NOT NULL DEFAULT 500, + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','active','ended','sold','cancelled')), + winner_id UUID REFERENCES users(id), + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Ставки +CREATE TABLE bids ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + auction_id UUID REFERENCES auctions(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + amount NUMERIC(12,2) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Подписки +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT, + confirm_token TEXT NOT NULL DEFAULT gen_random_uuid()::text, + unsub_token TEXT NOT NULL DEFAULT gen_random_uuid()::text, + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Категории подписки (опционально) +CREATE TABLE subscription_categories ( + subscription_id UUID REFERENCES subscriptions(id) ON DELETE CASCADE, + category_id INT REFERENCES categories(id) ON DELETE CASCADE, + PRIMARY KEY (subscription_id, category_id) +); + +-- История рассылок +CREATE TABLE newsletter_sends ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subject TEXT NOT NULL, + body_html TEXT NOT NULL, + recipient_count INT NOT NULL DEFAULT 0, + sent_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Комментарии +CREATE TABLE comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','approved','rejected')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Лайки +CREATE TABLE likes ( + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (work_id, user_id) +); + +-- Отзывы +CREATE TABLE reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (work_id, user_id) +); + +-- Уведомления владельца +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID REFERENCES users(id), + type TEXT NOT NULL + CHECK (type IN ('comment','like','review','bid','sale','new_bid_lost')), + entity_id UUID, -- id комментария / лайка / ставки / аукциона + preview_text TEXT, -- «Иван: "Красиво!" (60 символов)» + admin_url TEXT, -- /admin/comments/uuid + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Telegram-синхронизация +CREATE TABLE telegram_sync ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL, + message_id BIGINT NOT NULL, + direction TEXT NOT NULL DEFAULT 'push' CHECK (direction IN ('push','pull')), + synced_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Индексы +CREATE INDEX idx_works_status ON works(status); +CREATE INDEX idx_works_slug ON works(slug); +CREATE INDEX idx_auctions_status ON auctions(status); +CREATE INDEX idx_auctions_end_at ON auctions(end_at); +CREATE INDEX idx_bids_auction ON bids(auction_id, created_at DESC); +CREATE INDEX idx_comments_work ON comments(work_id, status); +CREATE INDEX idx_notifications ON notifications(owner_id, read_at, created_at DESC); + +-- View: работы с агрегатами +CREATE VIEW works_extended AS +SELECT + w.*, + COUNT(DISTINCT l.user_id) AS likes_count, + COUNT(DISTINCT c.id) + FILTER (WHERE c.status='approved') AS comments_count, + AVG(r.rating) AS avg_rating, + COUNT(DISTINCT r.id) AS reviews_count, + m.url AS cover_url, + m.thumb_url AS cover_thumb +FROM works w +LEFT JOIN likes l ON l.work_id = w.id +LEFT JOIN comments c ON c.work_id = w.id +LEFT JOIN reviews r ON r.work_id = w.id +LEFT JOIN LATERAL ( + SELECT url, thumb_url FROM media + WHERE work_id = w.id ORDER BY sort_order LIMIT 1 +) m ON true +GROUP BY w.id, m.url, m.thumb_url; diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..9642995 --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,8 @@ +-- Seed: тестовые данные +-- Категории +INSERT INTO categories (name, slug, sort_order) VALUES + ('Живопись', 'painting', 1), + ('Иллюстрация', 'illustration', 2), + ('Графика', 'graphics', 3), + ('Цифровое искусство', 'digital', 4), + ('Текстиль', 'textile', 5); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..88c09b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +# Секреты только из .env — не хардкодить здесь. +services: + db: + image: postgres:16-alpine + env_file: .env + environment: + POSTGRES_DB: nika_gallery + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro + - ./db/seed.sql:/docker-entrypoint-initdb.d/02_seed.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 3s + retries: 10 + + backend: + build: ./backend + env_file: .env + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:4000:4000" + volumes: + - ./uploads:/app/uploads + + frontend: + build: ./frontend + env_file: .env + depends_on: + - backend + ports: + - "127.0.0.1:3000:3000" + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + - ./uploads:/var/www/uploads:ro + depends_on: + - frontend + - backend + +volumes: + db_data: diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..5b01816 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,48 @@ +events { worker_connections 1024; } + +http { + gzip on; + gzip_types text/plain text/css application/json application/javascript; + + server { + listen 80; + server_name nika-gallery.ru www.nika-gallery.ru; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + server_name nika-gallery.ru www.nika-gallery.ru; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + # Медиа-файлы — отдаёт Nginx напрямую + location /uploads/ { + alias /var/www/uploads/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # API + WebSocket + location /api/ { + proxy_pass http://backend:4000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /ws/ { + proxy_pass http://backend:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Next.js SPA + location / { + proxy_pass http://frontend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +}