From 4553f63debe1ccb48bffb60c96d27c064c17650e Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Apr 2026 21:24:26 +0300 Subject: [PATCH] init: M1 scaffolding + M2 organization/clients/services CRUD - monorepo (npm workspaces): apps/api (Fastify+Prisma+TS), apps/web (Vite+React+TS), packages/shared (zod schemas) - SSO via auth.queo.ru: jose+JWKS plugin, requireDocPermission(viewer|user|admin) - DEV_BYPASS_AUTH for local development (hard-checked off in production) - M2: organization upsert, clients CRUD with search, services catalog with soft-delete - BigInt -> Number serializer for Prisma money columns - Embedded Postgres + npm run dev:demo for one-command local boot - Docker compose for queoserver: postgres + api + web (nginx as ingress proxying /api -> api:3030) - First migration 0_init committed (prisma migrate diff) Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 12 + .gitignore | 40 + README.md | 106 + apps/api/.env.example | 36 + apps/api/package.json | 37 + .../prisma/migrations/0_init/migration.sql | 279 ++ .../api/prisma/migrations/migration_lock.toml | 3 + apps/api/prisma/schema.prisma | 250 + apps/api/prisma/seed.ts | 32 + apps/api/scripts/dev-server.ts | 129 + apps/api/src/db.ts | 5 + apps/api/src/env.ts | 41 + apps/api/src/lib/bigint.ts | 18 + apps/api/src/lib/org.ts | 11 + apps/api/src/modules/clients/routes.ts | 111 + apps/api/src/modules/organizations/routes.ts | 53 + apps/api/src/modules/services/routes.ts | 121 + apps/api/src/plugins/auth.ts | 84 + apps/api/src/routes/health.ts | 16 + apps/api/src/routes/me.ts | 15 + apps/api/src/server.ts | 55 + apps/api/tsconfig.json | 14 + apps/web/.env.example | 2 + apps/web/index.html | 12 + apps/web/package.json | 25 + apps/web/src/App.tsx | 72 + apps/web/src/api.ts | 74 + apps/web/src/auth.ts | 58 + apps/web/src/components/ui.tsx | 109 + apps/web/src/main.tsx | 13 + apps/web/src/pages/Clients.tsx | 189 + apps/web/src/pages/Organization.tsx | 134 + apps/web/src/pages/Services.tsx | 250 + apps/web/src/styles.css | 166 + apps/web/tsconfig.json | 14 + apps/web/tsconfig.tsbuildinfo | 1 + apps/web/vite.config.ts | 16 + docker/.env.example | 19 + docker/Caddyfile | 24 + docker/Dockerfile.api | 31 + docker/Dockerfile.web | 14 + docker/docker-compose.yml | 61 + docker/nginx-spa.conf | 48 + package-lock.json | 4011 +++++++++++++++++ package.json | 27 + packages/shared/package.json | 23 + packages/shared/src/auth/types.ts | 30 + packages/shared/src/blocks/schema.ts | 115 + packages/shared/src/index.ts | 3 + packages/shared/src/tochka/dto.ts | 74 + packages/shared/tsconfig.json | 10 + tsconfig.base.json | 17 + 52 files changed, 7110 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma/migrations/0_init/migration.sql create mode 100644 apps/api/prisma/migrations/migration_lock.toml create mode 100644 apps/api/prisma/schema.prisma create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/scripts/dev-server.ts create mode 100644 apps/api/src/db.ts create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/lib/bigint.ts create mode 100644 apps/api/src/lib/org.ts create mode 100644 apps/api/src/modules/clients/routes.ts create mode 100644 apps/api/src/modules/organizations/routes.ts create mode 100644 apps/api/src/modules/services/routes.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/routes/health.ts create mode 100644 apps/api/src/routes/me.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/.env.example create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/auth.ts create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/Clients.tsx create mode 100644 apps/web/src/pages/Organization.tsx create mode 100644 apps/web/src/pages/Services.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 apps/web/vite.config.ts create mode 100644 docker/.env.example create mode 100644 docker/Caddyfile create mode 100644 docker/Dockerfile.api create mode 100644 docker/Dockerfile.web create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx-spa.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/auth/types.ts create mode 100644 packages/shared/src/blocks/schema.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/tochka/dto.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8c52ff9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45d52c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +node_modules/ +dist/ +build/ +.next/ +.cache/ +.turbo/ +coverage/ + +# env +.env +.env.local +.env.*.local +!.env.example + +# logs +*.log +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* + +# editors +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# prisma generated +apps/api/prisma/migrations/dev.db* + +# generated PDFs/uploads +storage/ +data/ + + +# OS +*.swp +*.swo + +# Claude Code local +.claude/settings.local.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..921532d --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Doc_manager + +Сервис создания договоров, счетов и актов/УПД с интеграцией банка Точка. Часть экосистемы Queo. + +## Стек + +- **Backend** — Node.js 20+ / Fastify / Prisma / PostgreSQL (TypeScript, ESM). +- **Frontend** — React + Vite + TipTap (TypeScript). +- **Auth** — SSO через `auth.queo.ru` (RS256 JWT в cookie `q_at`, JWKS-валидация). +- **Bank** — Точка API: счета и акты/УПД создаются на стороне банка, договоры — наши. + +## Структура + +``` +apps/ + api/ # Fastify + Prisma + web/ # React + Vite +packages/ + shared/ # zod-схемы и типы (DocBody, Tochka DTOs) +docker/ + docker-compose.yml + Caddyfile +``` + +## Подготовка перед разработкой + +1. **Node.js ≥ 20** — установлен. +2. **PostgreSQL** — локально или через Docker (`docker compose up postgres`). +3. **Auth_server** должен принимать редиректы на `doc.queo.ru` — добавить в `C:\project\Auth_server\.env`: + ``` + ALLOWED_RETURN_TO_HOSTS=auth.queo.ru,hall.queo.ru,cloud.queo.ru,doc.queo.ru + ``` + +## Быстрый старт «пощупать прямо сейчас» + +Нет Postgres / Docker / доступа к auth.queo.ru — всё равно работает: + +```bash +npm install +npm run dev:demo +``` + +Что делает `dev:demo`: +- Запускает встроенный Postgres из `node_modules` (первый раз скачает ~80MB binaries в `data/embedded-pg/`). +- Накатывает схему через `prisma db push`. +- Сидит организацию `DEFAULT_ORGANIZATION_ID`. +- Поднимает API на `:3030` и web на `:5173` параллельно. +- В API включает `DEV_BYPASS_AUTH=1` — фейковый admin без auth.queo.ru. + +Открыть `http://localhost:5173/` — попадаешь сразу в шелл (без логина), можно ходить: +- `/organization` — реквизиты компании, +- `/clients` — клиенты, +- `/services` — каталог услуг, +- остальное — заглушки до M3+. + +Данные между перезапусками **сохраняются** в `data/embedded-pg/`. Чтобы начать с нуля — удалить эту папку. + +⚠️ **Безопасность:** `DEV_BYPASS_AUTH=1` обходит JWT. API hard-checks: при `NODE_ENV=production` с этим флагом процесс отказывается стартовать. + +## Полная установка (с реальным auth.queo.ru) + +```bash +npm install +cp apps/api/.env.example apps/api/.env +# отредактировать DATABASE_URL, оставить DEV_BYPASS_AUTH=0 +npm run prisma:migrate +npm run -w @doc-manager/api prisma:seed +npm run dev # api+web с настоящей SSO-авторизацией +``` + +API на `http://localhost:3030`, web — на `http://localhost:5173`. Перед этим добавить `doc.queo.ru` в `ALLOWED_RETURN_TO_HOSTS` в `C:\project\Auth_server\.env`. + +## Тесты M1 (smoke) + +- `GET http://localhost:3030/health` → `{ ok: true }` +- Открыть `http://localhost:5173` без cookie `q_at` → редирект на `https://auth.queo.ru/auth/login?return_to=...`. +- С валидной cookie от auth.queo.ru — `/api/me` отдаёт payload JWT. + +## Тесты M2 (организация, клиенты, услуги) + +С Postgres и применённой миграцией: + +1. Поднять БД и применить миграцию: + ```bash + docker compose -f docker/docker-compose.yml up -d postgres + npm run prisma:migrate # создаст первую миграцию + npm run -w @doc-manager/api prisma:seed # сидит DEFAULT_ORGANIZATION_ID + ``` +2. `GET /api/organization` → возвращает запись организации (после сида). +3. `GET /api/clients?q=...` — поиск по name/inn/email. +4. `GET /api/services?includeArchived=1` — каталог + архив. +5. В UI: `/organization` — заполнить реквизиты; `/clients` — добавить клиента; `/services` — добавить услугу с ценой и НДС. + +SQL-предпросмотр миграции без БД: `apps/api/prisma/init.sql.preview` (генерируется через `prisma migrate diff`). + +## План реализации + +См. `C:\Users\VVMedia\.claude\plans\floofy-baking-jellyfish.md`. Этапы M1–M7. + +## Деплой (домашний сервер) + +``` +ssh -i ~/.ssh/id_sat vmv@192.168.0.158 +cd ~/Doc-manager +git pull && docker compose build && docker compose up -d +``` diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..23374c6 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,36 @@ +# --- API server --- +NODE_ENV=development +PORT=3030 +HOST=127.0.0.1 + +# --- Database --- +DATABASE_URL=postgresql://docmanager:docmanager@localhost:5432/docmanager?schema=public + +# --- SSO via auth.queo.ru --- +AUTH_ISSUER=https://auth.queo.ru +AUTH_AUDIENCE=queo.ru +AUTH_JWKS_URL=https://auth.queo.ru/.well-known/jwks.json +AUTH_COOKIE_NAME=q_at +AUTH_LOGIN_URL=https://auth.queo.ru/auth/login + +# --- CORS --- +# Допустимые источники для браузера (запятая). На проде: https://doc.queo.ru +CORS_ORIGINS=http://localhost:5173 + +# --- Tochka API --- +# 32 случайных байта в base64 — ключ AES-256-GCM для шифрования JWT-токенов Точки в БД. +# Генерация: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +TOCHKA_JWT_KEY= + +# --- Webhook secret для Точки --- +# Случайная строка, попадает в URL: /webhooks/tochka/ +TOCHKA_WEBHOOK_SECRET= + +# --- Default organization (single-tenant v1) --- +# UUID единственной организации — сидится в M2. +DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001 + +# --- Dev-only --- +# Если 1 — пропускает проверку JWT и подсовывает фейкового admin'а. +# В production отказывается стартовать с этой переменной. +DEV_BYPASS_AUTH=0 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..d41b4bb --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "@doc-manager/api", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "typecheck": "tsc --noEmit", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "tsx prisma/seed.ts", + "dev:demo": "tsx scripts/dev-server.ts" + }, + "dependencies": { + "@doc-manager/shared": "*", + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "@fastify/helmet": "^11.1.1", + "@prisma/client": "^5.22.0", + "fastify": "^4.28.1", + "fastify-plugin": "^4.5.1", + "jose": "^5.9.6", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.16.0", + "embedded-postgres": "^18.3.0-beta.17", + "pino-pretty": "^11.3.0", + "prisma": "^5.22.0", + "tsx": "^4.19.0", + "typescript": "^5.6.3" + } +} diff --git a/apps/api/prisma/migrations/0_init/migration.sql b/apps/api/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..74f3c3f --- /dev/null +++ b/apps/api/prisma/migrations/0_init/migration.sql @@ -0,0 +1,279 @@ +-- CreateEnum +CREATE TYPE "DocType" AS ENUM ('contract', 'invoice', 'act', 'upd'); + +-- CreateEnum +CREATE TYPE "DocStatus" AS ENUM ('draft', 'issued', 'sent', 'partially_paid', 'paid', 'cancelled', 'signed'); + +-- CreateEnum +CREATE TYPE "VatRate" AS ENUM ('none', '0', '5', '7', '10', '20'); + +-- CreateEnum +CREATE TYPE "ClientKind" AS ENUM ('ul', 'ip', 'fl'); + +-- CreateEnum +CREATE TYPE "TochkaEnv" AS ENUM ('sandbox', 'prod'); + +-- CreateEnum +CREATE TYPE "PaymentKind" AS ENUM ('incoming', 'incoming_sbp', 'incoming_sbp_b2b', 'outgoing'); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "inn" TEXT NOT NULL, + "kpp" TEXT, + "ogrn" TEXT, + "legalAddress" TEXT, + "bankName" TEXT, + "bankBik" TEXT, + "bankAccount" TEXT, + "signatoryName" TEXT, + "signatoryPosition" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Client" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "kind" "ClientKind" NOT NULL, + "name" TEXT NOT NULL, + "inn" TEXT, + "kpp" TEXT, + "address" TEXT, + "email" TEXT, + "phone" TEXT, + "contactPerson" TEXT, + "requisitesJson" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Client_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServiceCatalog" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "name" TEXT NOT NULL, + "unit" TEXT NOT NULL, + "defaultPriceCents" BIGINT NOT NULL DEFAULT 0, + "defaultVat" "VatRate" NOT NULL DEFAULT 'none', + "notes" TEXT, + "archivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServiceCatalog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentTemplate" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "docType" "DocType" NOT NULL, + "name" TEXT NOT NULL, + "body" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DocumentTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Document" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "docType" "DocType" NOT NULL, + "number" TEXT NOT NULL, + "issuedAt" TIMESTAMP(3), + "status" "DocStatus" NOT NULL DEFAULT 'draft', + "clientId" UUID, + "parentDocumentId" UUID, + "body" JSONB NOT NULL, + "totalCents" BIGINT NOT NULL DEFAULT 0, + "vatCents" BIGINT NOT NULL DEFAULT 0, + "currency" TEXT NOT NULL DEFAULT 'RUB', + "tochkaDocumentId" TEXT, + "tochkaEnvironment" "TochkaEnv", + "pdfPath" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DocumentLine" ( + "id" UUID NOT NULL, + "documentId" UUID NOT NULL, + "position" INTEGER NOT NULL, + "serviceId" UUID, + "name" TEXT NOT NULL, + "qtyMilli" BIGINT NOT NULL DEFAULT 1000, + "unit" TEXT NOT NULL, + "priceCents" BIGINT NOT NULL, + "vat" "VatRate" NOT NULL DEFAULT 'none', + "sumCents" BIGINT NOT NULL, + + CONSTRAINT "DocumentLine_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "documentId" UUID, + "tochkaPaymentId" TEXT NOT NULL, + "kind" "PaymentKind" NOT NULL, + "amountCents" BIGINT NOT NULL, + "payerInn" TEXT, + "payerName" TEXT, + "purpose" TEXT, + "paidAt" TIMESTAMP(3), + "raw" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TochkaCredential" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "environment" "TochkaEnv" NOT NULL, + "jwtEncrypted" TEXT NOT NULL, + "customerCode" TEXT NOT NULL, + "accountCode" TEXT, + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TochkaCredential_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WebhookEvent" ( + "id" UUID NOT NULL, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "source" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "raw" JSONB NOT NULL, + "processedAt" TIMESTAMP(3), + "error" TEXT, + "dedupeKey" TEXT NOT NULL, + + CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" UUID NOT NULL, + "organizationId" UUID NOT NULL, + "actorSub" TEXT, + "action" TEXT NOT NULL, + "entity" TEXT NOT NULL, + "entityId" TEXT NOT NULL, + "diff" JSONB, + "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Client_organizationId_idx" ON "Client"("organizationId"); + +-- CreateIndex +CREATE INDEX "Client_organizationId_name_idx" ON "Client"("organizationId", "name"); + +-- CreateIndex +CREATE INDEX "ServiceCatalog_organizationId_idx" ON "ServiceCatalog"("organizationId"); + +-- CreateIndex +CREATE INDEX "ServiceCatalog_organizationId_archivedAt_idx" ON "ServiceCatalog"("organizationId", "archivedAt"); + +-- CreateIndex +CREATE INDEX "DocumentTemplate_organizationId_docType_idx" ON "DocumentTemplate"("organizationId", "docType"); + +-- CreateIndex +CREATE INDEX "Document_organizationId_clientId_issuedAt_idx" ON "Document"("organizationId", "clientId", "issuedAt" DESC); + +-- CreateIndex +CREATE INDEX "Document_organizationId_status_idx" ON "Document"("organizationId", "status"); + +-- CreateIndex +CREATE INDEX "Document_tochkaDocumentId_idx" ON "Document"("tochkaDocumentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Document_organizationId_docType_number_key" ON "Document"("organizationId", "docType", "number"); + +-- CreateIndex +CREATE INDEX "DocumentLine_documentId_idx" ON "DocumentLine"("documentId"); + +-- CreateIndex +CREATE INDEX "DocumentLine_serviceId_idx" ON "DocumentLine"("serviceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_tochkaPaymentId_key" ON "Payment"("tochkaPaymentId"); + +-- CreateIndex +CREATE INDEX "Payment_organizationId_paidAt_idx" ON "Payment"("organizationId", "paidAt" DESC); + +-- CreateIndex +CREATE INDEX "Payment_documentId_idx" ON "Payment"("documentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TochkaCredential_organizationId_environment_key" ON "TochkaCredential"("organizationId", "environment"); + +-- CreateIndex +CREATE UNIQUE INDEX "WebhookEvent_dedupeKey_key" ON "WebhookEvent"("dedupeKey"); + +-- CreateIndex +CREATE INDEX "WebhookEvent_source_eventType_receivedAt_idx" ON "WebhookEvent"("source", "eventType", "receivedAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_organizationId_at_idx" ON "AuditLog"("organizationId", "at" DESC); + +-- CreateIndex +CREATE INDEX "AuditLog_entity_entityId_idx" ON "AuditLog"("entity", "entityId"); + +-- AddForeignKey +ALTER TABLE "Client" ADD CONSTRAINT "Client_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceCatalog" ADD CONSTRAINT "ServiceCatalog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentTemplate" ADD CONSTRAINT "DocumentTemplate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_parentDocumentId_fkey" FOREIGN KEY ("parentDocumentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DocumentLine" ADD CONSTRAINT "DocumentLine_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "ServiceCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TochkaCredential" ADD CONSTRAINT "TochkaCredential_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..3ff8575 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,250 @@ +// Doc_manager — модель данных +// Полиморфная таблица documents для contract/invoice/act/upd. +// organization_id присутствует на всех owner-scoped таблицах (single-tenant v1, готово к multi-tenant). + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum DocType { + contract + invoice + act + upd +} + +enum DocStatus { + draft + issued + sent + partially_paid + paid + cancelled + signed +} + +enum VatRate { + none + vat_0 @map("0") + vat_5 @map("5") + vat_7 @map("7") + vat_10 @map("10") + vat_20 @map("20") +} + +enum ClientKind { + ul // юр.лицо + ip // ИП + fl // физ.лицо +} + +enum TochkaEnv { + sandbox + prod +} + +enum PaymentKind { + incoming + incoming_sbp + incoming_sbp_b2b + outgoing +} + +model Organization { + id String @id @default(uuid()) @db.Uuid + name String + inn String + kpp String? + ogrn String? + legalAddress String? + bankName String? + bankBik String? + bankAccount String? + signatoryName String? + signatoryPosition String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + clients Client[] + servicesCatalog ServiceCatalog[] + templates DocumentTemplate[] + documents Document[] + payments Payment[] + tochkaCredentials TochkaCredential[] + auditLog AuditLog[] +} + +model Client { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + kind ClientKind + name String + inn String? + kpp String? + address String? + email String? + phone String? + contactPerson String? + requisitesJson Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + documents Document[] + + @@index([organizationId]) + @@index([organizationId, name]) +} + +model ServiceCatalog { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + name String + unit String // "шт", "час", "мес" + defaultPriceCents BigInt @default(0) + defaultVat VatRate @default(none) + notes String? + archivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lines DocumentLine[] + + @@index([organizationId]) + @@index([organizationId, archivedAt]) +} + +model DocumentTemplate { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + docType DocType + name String + body Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId, docType]) +} + +model Document { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + docType DocType + number String + issuedAt DateTime? + status DocStatus @default(draft) + clientId String? @db.Uuid + client Client? @relation(fields: [clientId], references: [id]) + parentDocumentId String? @db.Uuid + parent Document? @relation("DocumentChildren", fields: [parentDocumentId], references: [id]) + children Document[] @relation("DocumentChildren") + body Json + totalCents BigInt @default(0) + vatCents BigInt @default(0) + currency String @default("RUB") + tochkaDocumentId String? + tochkaEnvironment TochkaEnv? + pdfPath String? + createdBy String? // sub из JWT auth.queo.ru + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lines DocumentLine[] + payments Payment[] + + @@unique([organizationId, docType, number]) + @@index([organizationId, clientId, issuedAt(sort: Desc)]) + @@index([organizationId, status]) + @@index([tochkaDocumentId]) +} + +model DocumentLine { + id String @id @default(uuid()) @db.Uuid + documentId String @db.Uuid + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + position Int + serviceId String? @db.Uuid + service ServiceCatalog? @relation(fields: [serviceId], references: [id]) + name String + // Количество хранится как milli-units (тысячные), чтобы поддержать дробные количества без float. + qtyMilli BigInt @default(1000) + unit String + priceCents BigInt + vat VatRate @default(none) + sumCents BigInt + + @@index([documentId]) + @@index([serviceId]) +} + +model Payment { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + documentId String? @db.Uuid + document Document? @relation(fields: [documentId], references: [id]) + tochkaPaymentId String @unique + kind PaymentKind + amountCents BigInt + payerInn String? + payerName String? + purpose String? + paidAt DateTime? + raw Json + createdAt DateTime @default(now()) + + @@index([organizationId, paidAt(sort: Desc)]) + @@index([documentId]) +} + +model TochkaCredential { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + environment TochkaEnv + // AES-256-GCM ciphertext (iv|tag|ct), base64 + jwtEncrypted String + customerCode String + accountCode String? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([organizationId, environment]) +} + +model WebhookEvent { + id String @id @default(uuid()) @db.Uuid + receivedAt DateTime @default(now()) + source String // 'tochka' + eventType String + raw Json + processedAt DateTime? + error String? + dedupeKey String @unique + + @@index([source, eventType, receivedAt]) +} + +model AuditLog { + id String @id @default(uuid()) @db.Uuid + organizationId String @db.Uuid + organization Organization @relation(fields: [organizationId], references: [id]) + actorSub String? + action String + entity String + entityId String + diff Json? + at DateTime @default(now()) + + @@index([organizationId, at(sort: Desc)]) + @@index([entity, entityId]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..21f8c39 --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,32 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const id = process.env.DEFAULT_ORGANIZATION_ID ?? '00000000-0000-0000-0000-000000000001'; + + const existing = await prisma.organization.findUnique({ where: { id } }); + if (existing) { + console.log(`organization ${id} already exists — skipping seed`); + return; + } + + await prisma.organization.create({ + data: { + id, + name: 'ООО «Моя компания»', + inn: '0000000000', + // Остальные реквизиты пользователь заполнит через UI на странице «Реквизиты». + }, + }); + console.log(`organization ${id} created (заполните реквизиты в UI: /organization)`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/scripts/dev-server.ts b/apps/api/scripts/dev-server.ts new file mode 100644 index 0000000..2942368 --- /dev/null +++ b/apps/api/scripts/dev-server.ts @@ -0,0 +1,129 @@ +/** + * Dev orchestrator: embedded Postgres → prisma db push → seed → API server. + * Запуск: npm run dev:demo (в корне) или tsx scripts/dev-server.ts (в apps/api). + * + * Использовать ТОЛЬКО для локальной разработки. Защита: процесс выходит, + * если NODE_ENV=production или DEV_BYPASS_AUTH не включён (см. ниже). + */ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import EmbeddedPostgres from 'embedded-postgres'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const apiRoot = resolve(__dirname, '..'); + +const PG_PORT = 5433; +const PG_USER = 'postgres'; +const PG_PASSWORD = 'postgres'; +const PG_DB = 'docmanager'; +const PG_DATA = resolve(apiRoot, '../../data/embedded-pg'); +const DATABASE_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${PG_PORT}/${PG_DB}?schema=public`; + +if (process.env.NODE_ENV === 'production') { + console.error('FATAL: dev-server.ts запрещён в production'); + process.exit(1); +} + +mkdirSync(dirname(PG_DATA), { recursive: true }); + +const pg = new EmbeddedPostgres({ + databaseDir: PG_DATA, + user: PG_USER, + password: PG_PASSWORD, + port: PG_PORT, + persistent: true, +}); + +async function exec(cmd: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((res, rej) => { + const child = spawn(cmd, args, { + stdio: 'inherit', + cwd: apiRoot, + env: { ...process.env, ...env }, + shell: process.platform === 'win32', + }); + child.on('exit', (code) => (code === 0 ? res() : rej(new Error(`${cmd} ${args.join(' ')} exited ${code}`)))); + child.on('error', rej); + }); +} + +let serverChild: ReturnType | null = null; +let stopping = false; + +async function shutdown(reason: string) { + if (stopping) return; + stopping = true; + console.log(`\n[dev-server] shutdown: ${reason}`); + try { + if (serverChild && !serverChild.killed) { + serverChild.kill('SIGTERM'); + await new Promise((r) => setTimeout(r, 500)); + } + } catch (e) { + console.warn('[dev-server] error stopping API:', e); + } + try { + await pg.stop(); + } catch (e) { + console.warn('[dev-server] error stopping pg:', e); + } + process.exit(0); +} + +process.on('SIGINT', () => void shutdown('SIGINT')); +process.on('SIGTERM', () => void shutdown('SIGTERM')); + +async function main() { + const dataInitialised = existsSync(resolve(PG_DATA, 'PG_VERSION')); + + if (!dataInitialised) { + console.log('[dev-server] initialising embedded Postgres (~80MB binaries on first run)…'); + await pg.initialise(); + } + + console.log(`[dev-server] starting Postgres on :${PG_PORT}…`); + await pg.start(); + + if (!dataInitialised) { + console.log(`[dev-server] creating database "${PG_DB}"…`); + try { + await pg.createDatabase(PG_DB); + } catch (e) { + console.warn('[dev-server] createDatabase warning:', (e as Error).message); + } + } + + console.log('[dev-server] applying schema (prisma db push)…'); + await exec('npx', ['prisma', 'db', 'push', '--skip-generate', '--accept-data-loss'], { DATABASE_URL }); + + console.log('[dev-server] seeding default organization…'); + await exec('npx', ['tsx', 'prisma/seed.ts'], { DATABASE_URL }); + + console.log('[dev-server] starting API server…'); + serverChild = spawn('npx', ['tsx', 'watch', 'src/server.ts'], { + stdio: 'inherit', + cwd: apiRoot, + env: { + ...process.env, + DATABASE_URL, + DEV_BYPASS_AUTH: '1', + PORT: '3030', + HOST: '127.0.0.1', + NODE_ENV: 'development', + }, + shell: process.platform === 'win32', + }); + serverChild.on('exit', (code) => { + if (!stopping) { + console.error(`[dev-server] API exited unexpectedly (${code})`); + void shutdown('api-exit'); + } + }); +} + +main().catch((err) => { + console.error('[dev-server] fatal:', err); + void shutdown('fatal'); +}); diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..022e2bf --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], +}); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts new file mode 100644 index 0000000..4c8d197 --- /dev/null +++ b/apps/api/src/env.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +const EnvSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3030), + HOST: z.string().default('127.0.0.1'), + + DATABASE_URL: z.string().url(), + + AUTH_ISSUER: z.string().url().default('https://auth.queo.ru'), + AUTH_AUDIENCE: z.string().default('queo.ru'), + AUTH_JWKS_URL: z.string().url().default('https://auth.queo.ru/.well-known/jwks.json'), + AUTH_COOKIE_NAME: z.string().default('q_at'), + AUTH_LOGIN_URL: z.string().url().default('https://auth.queo.ru/auth/login'), + + CORS_ORIGINS: z + .string() + .default('http://localhost:5173') + .transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean)), + + TOCHKA_JWT_KEY: z.string().optional(), + TOCHKA_WEBHOOK_SECRET: z.string().optional(), + + DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'), + + // Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже. + DEV_BYPASS_AUTH: z + .string() + .optional() + .transform((v) => v === '1' || v === 'true'), +}); + +export type Env = z.infer; + +export const env: Env = EnvSchema.parse(process.env); + +if (env.NODE_ENV === 'production' && env.DEV_BYPASS_AUTH) { + // eslint-disable-next-line no-console + console.error('FATAL: DEV_BYPASS_AUTH=1 в production. Это обход аутентификации, отказываюсь стартовать.'); + process.exit(1); +} diff --git a/apps/api/src/lib/bigint.ts b/apps/api/src/lib/bigint.ts new file mode 100644 index 0000000..6bae2e4 --- /dev/null +++ b/apps/api/src/lib/bigint.ts @@ -0,0 +1,18 @@ +// Fastify сериализует ответы через JSON.stringify, который ругается на BigInt. +// В Doc_manager все денежные/количественные поля копеек/milliQty влезают в JS Number +// (2^53-1 ≈ 9 квадриллионов копеек) — конвертируем BigInt → Number один раз глобально. +// +// Если когда-нибудь понадобится точность выше Number.MAX_SAFE_INTEGER — переключим +// на сериализацию в строку и type-marshalling на фронте. + +declare global { + interface BigInt { + toJSON(): number; + } +} + +(BigInt.prototype as { toJSON?: () => number }).toJSON = function () { + return Number(this); +}; + +export {}; diff --git a/apps/api/src/lib/org.ts b/apps/api/src/lib/org.ts new file mode 100644 index 0000000..eb81c80 --- /dev/null +++ b/apps/api/src/lib/org.ts @@ -0,0 +1,11 @@ +import type { FastifyRequest } from 'fastify'; +import { env } from '../env.js'; + +/** + * Возвращает organization_id, в контексте которого работает запрос. + * Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID. + * При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership. + */ +export function getOrganizationId(_req: FastifyRequest): string { + return env.DEFAULT_ORGANIZATION_ID; +} diff --git a/apps/api/src/modules/clients/routes.ts b/apps/api/src/modules/clients/routes.ts new file mode 100644 index 0000000..365751b --- /dev/null +++ b/apps/api/src/modules/clients/routes.ts @@ -0,0 +1,111 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; + +const ClientUpsert = z.object({ + kind: z.enum(['ul', 'ip', 'fl']), + name: z.string().min(1).max(500), + inn: z + .string() + .regex(/^\d{10}$|^\d{12}$/) + .nullable(), + kpp: z.string().regex(/^\d{9}$/).nullable(), + address: z.string().max(1000).nullable(), + email: z.string().email().nullable(), + phone: z.string().max(50).nullable(), + contactPerson: z.string().max(200).nullable(), +}); + +const ListQuery = z.object({ + q: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), +}); + +export async function clientsRoutes(app: FastifyInstance) { + app.get('/api/clients', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = ListQuery.safeParse(req.query); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const { q, limit } = parsed.data; + const clients = await prisma.client.findMany({ + where: { + organizationId: orgId, + ...(q + ? { + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { inn: { contains: q } }, + { email: { contains: q, mode: 'insensitive' } }, + ], + } + : {}), + }, + orderBy: { name: 'asc' }, + take: limit, + }); + return { items: clients }; + }); + + app.get('/api/clients/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const client = await prisma.client.findFirst({ where: { id, organizationId: orgId } }); + if (!client) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return client; + }); + + app.post('/api/clients', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = ClientUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const created = await prisma.client.create({ + data: { ...parsed.data, organizationId: orgId }, + }); + reply.code(201).send(created); + }); + + app.put('/api/clients/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = ClientUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const updated = await prisma.client.update({ where: { id }, data: parsed.data }); + return updated; + }); + + app.delete('/api/clients/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.client.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + // Не используем onDelete: Cascade на documents.clientId — клиента с документами лучше архивировать. + const docCount = await prisma.document.count({ where: { clientId: id } }); + if (docCount > 0) { + reply.code(409).send({ error: 'has_documents', count: docCount }); + return; + } + await prisma.client.delete({ where: { id } }); + reply.code(204).send(); + }); +} diff --git a/apps/api/src/modules/organizations/routes.ts b/apps/api/src/modules/organizations/routes.ts new file mode 100644 index 0000000..0b85386 --- /dev/null +++ b/apps/api/src/modules/organizations/routes.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; + +const OrgUpdate = z.object({ + name: z.string().min(1).max(500), + inn: z.string().regex(/^\d{10}$|^\d{12}$/), + kpp: z.string().regex(/^\d{9}$/).nullable(), + ogrn: z.string().regex(/^\d{13}$|^\d{15}$/).nullable(), + legalAddress: z.string().max(1000).nullable(), + bankName: z.string().max(500).nullable(), + bankBik: z.string().regex(/^\d{9}$/).nullable(), + bankAccount: z.string().regex(/^\d{20}$/).nullable(), + signatoryName: z.string().max(500).nullable(), + signatoryPosition: z.string().max(500).nullable(), +}); + +export async function organizationsRoutes(app: FastifyInstance) { + app.get( + '/api/organization', + { preHandler: app.requireDocPermission('viewer') }, + async (req, reply) => { + const id = getOrganizationId(req); + const org = await prisma.organization.findUnique({ where: { id } }); + if (!org) { + reply.code(404).send({ error: 'organization_not_found' }); + return; + } + return org; + }, + ); + + app.put( + '/api/organization', + { preHandler: app.requireDocPermission('admin') }, + async (req, reply) => { + const id = getOrganizationId(req); + const parsed = OrgUpdate.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + // upsert чтобы первое сохранение из UI создавало строку, если её ещё нет (вместо seed-only) + const org = await prisma.organization.upsert({ + where: { id }, + update: parsed.data, + create: { id, ...parsed.data }, + }); + return org; + }, + ); +} diff --git a/apps/api/src/modules/services/routes.ts b/apps/api/src/modules/services/routes.ts new file mode 100644 index 0000000..19d3bef --- /dev/null +++ b/apps/api/src/modules/services/routes.ts @@ -0,0 +1,121 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../../db.js'; +import { getOrganizationId } from '../../lib/org.js'; + +const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']); + +const ServiceUpsert = z.object({ + name: z.string().min(1).max(500), + unit: z.string().min(1).max(50), + defaultPriceCents: z.coerce.number().int().nonnegative(), + defaultVat: VatRate.default('none'), + notes: z.string().max(2000).nullable(), +}); + +const ListQuery = z.object({ + q: z.string().optional(), + includeArchived: z.coerce.boolean().default(false), + limit: z.coerce.number().int().min(1).max(500).default(200), +}); + +export async function servicesRoutes(app: FastifyInstance) { + app.get('/api/services', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = ListQuery.safeParse(req.query); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const { q, includeArchived, limit } = parsed.data; + const services = await prisma.serviceCatalog.findMany({ + where: { + organizationId: orgId, + ...(includeArchived ? {} : { archivedAt: null }), + ...(q + ? { + OR: [ + { name: { contains: q, mode: 'insensitive' } }, + { notes: { contains: q, mode: 'insensitive' } }, + ], + } + : {}), + }, + orderBy: { name: 'asc' }, + take: limit, + }); + return { items: services }; + }); + + app.post('/api/services', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const parsed = ServiceUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const created = await prisma.serviceCatalog.create({ + data: { + organizationId: orgId, + name: parsed.data.name, + unit: parsed.data.unit, + defaultPriceCents: BigInt(parsed.data.defaultPriceCents), + defaultVat: parsed.data.defaultVat, + notes: parsed.data.notes ?? null, + }, + }); + reply.code(201).send(created); + }); + + app.put('/api/services/:id', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const parsed = ServiceUpsert.safeParse(req.body); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + const updated = await prisma.serviceCatalog.update({ + where: { id }, + data: { + name: parsed.data.name, + unit: parsed.data.unit, + defaultPriceCents: BigInt(parsed.data.defaultPriceCents), + defaultVat: parsed.data.defaultVat, + notes: parsed.data.notes ?? null, + }, + }); + return updated; + }); + + // Архивация — soft delete. Жёстко удалять нельзя: на услугу могут ссылаться document_lines. + app.post('/api/services/:id/archive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return prisma.serviceCatalog.update({ + where: { id }, + data: { archivedAt: existing.archivedAt ?? new Date() }, + }); + }); + + app.post('/api/services/:id/unarchive', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const orgId = getOrganizationId(req); + const { id } = req.params as { id: string }; + const existing = await prisma.serviceCatalog.findFirst({ where: { id, organizationId: orgId } }); + if (!existing) { + reply.code(404).send({ error: 'not_found' }); + return; + } + return prisma.serviceCatalog.update({ where: { id }, data: { archivedAt: null } }); + }); +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..68822a9 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,84 @@ +import fp from 'fastify-plugin'; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import { + AuthPayload, + hasDocPermission, + type AuthPayload as AuthPayloadT, + type PermissionRole, +} from '@doc-manager/shared'; +import { env } from '../env.js'; + +declare module 'fastify' { + interface FastifyRequest { + user: AuthPayloadT | null; + } + interface FastifyInstance { + requireAuth: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise; + requireDocPermission: ( + level: PermissionRole, + ) => (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise; + } +} + +// Один JWKS-резолвер на процесс, jose сам кэширует ключи. +// Не создаём, если включён dev-bypass — лишний DNS на boot. +const jwks = env.DEV_BYPASS_AUTH ? null : createRemoteJWKSet(new URL(env.AUTH_JWKS_URL)); + +const DEV_FAKE_USER: AuthPayloadT = { + sub: '11111111-1111-1111-1111-111111111111', + email: 'dev@local', + groups: ['dev'], + permissions: { doc_manager: 'admin' }, + isSuperuser: true, +}; + +export default fp(async function authPlugin(app) { + app.decorateRequest('user', null); + + if (env.DEV_BYPASS_AUTH) { + app.log.warn('!!! DEV_BYPASS_AUTH ON — auth fully bypassed, fake admin injected !!!'); + } + + app.decorate('requireAuth', async function requireAuth(req, reply) { + if (env.DEV_BYPASS_AUTH) { + req.user = DEV_FAKE_USER; + return; + } + const token = req.cookies?.[env.AUTH_COOKIE_NAME]; + if (!token) { + reply.code(401).send({ error: 'no_token' }); + return; + } + try { + const { payload } = await jwtVerify(token, jwks!, { + issuer: env.AUTH_ISSUER, + audience: env.AUTH_AUDIENCE, + }); + const parsed = AuthPayload.safeParse(payload); + if (!parsed.success) { + app.log.warn({ err: parsed.error.flatten() }, 'auth: payload schema mismatch'); + reply.code(401).send({ error: 'invalid_payload' }); + return; + } + req.user = parsed.data; + } catch (e) { + const code = + (e as { code?: string } | null)?.code === 'ERR_JWT_EXPIRED' + ? 'token_expired' + : 'invalid_token'; + reply.code(401).send({ error: code }); + } + }); + + app.decorate('requireDocPermission', function requireDocPermission(level: PermissionRole) { + return async (req, reply) => { + if (!req.user) { + await app.requireAuth(req, reply); + if (reply.sent) return; + } + if (!req.user || !hasDocPermission(req.user, level)) { + reply.code(403).send({ error: 'forbidden' }); + } + }; + }); +}); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..56161bd --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify'; +import { prisma } from '../db.js'; + +export async function healthRoutes(app: FastifyInstance) { + app.get('/health', async () => ({ ok: true, ts: new Date().toISOString() })); + + app.get('/health/db', async (_req, reply) => { + try { + await prisma.$queryRaw`SELECT 1`; + return { ok: true }; + } catch (e) { + app.log.error({ err: e }, 'db health check failed'); + reply.code(503).send({ ok: false, error: 'db_unavailable' }); + } + }); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts new file mode 100644 index 0000000..ab907f2 --- /dev/null +++ b/apps/api/src/routes/me.ts @@ -0,0 +1,15 @@ +import type { FastifyInstance } from 'fastify'; +import { DOC_MANAGER_RESOURCE } from '@doc-manager/shared'; + +export async function meRoutes(app: FastifyInstance) { + app.get('/api/me', { preHandler: app.requireAuth }, async (req) => { + const u = req.user!; + return { + sub: u.sub, + email: u.email, + groups: u.groups, + isSuperuser: u.isSuperuser, + docPermission: u.permissions[DOC_MANAGER_RESOURCE] ?? null, + }; + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..85bff5e --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,55 @@ +import './lib/bigint.js'; // глобальный BigInt → number в JSON.stringify +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import cors from '@fastify/cors'; +import helmet from '@fastify/helmet'; +import { env } from './env.js'; +import authPlugin from './plugins/auth.js'; +import { healthRoutes } from './routes/health.js'; +import { meRoutes } from './routes/me.js'; +import { organizationsRoutes } from './modules/organizations/routes.js'; +import { clientsRoutes } from './modules/clients/routes.js'; +import { servicesRoutes } from './modules/services/routes.js'; + +async function main() { + const loggerOptions = + env.NODE_ENV === 'development' + ? { + level: 'debug', + transport: { + target: 'pino-pretty', + options: { translateTime: 'HH:MM:ss', ignore: 'pid,hostname' }, + }, + } + : { level: 'info' }; + + const app = Fastify({ logger: loggerOptions, trustProxy: true }); + + await app.register(helmet, { contentSecurityPolicy: false }); + await app.register(cors, { + origin: env.CORS_ORIGINS, + credentials: true, + }); + await app.register(cookie); + await app.register(authPlugin); + + await app.register(healthRoutes); + await app.register(meRoutes); + await app.register(organizationsRoutes); + await app.register(clientsRoutes); + await app.register(servicesRoutes); + + app.setErrorHandler((err, _req, reply) => { + app.log.error({ err }, 'unhandled error'); + if (reply.sent) return; + reply.code(err.statusCode ?? 500).send({ error: err.code ?? 'internal_error' }); + }); + + await app.listen({ port: env.PORT, host: env.HOST }); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('fatal:', err); + process.exit(1); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..b600567 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..5649ca1 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,2 @@ +# URL центра аутентификации Queo +VITE_AUTH_LOGIN_URL=https://auth.queo.ru/auth/login diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..872daac --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Doc_manager — Queo + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..15b3b52 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@doc-manager/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@doc-manager/shared": "*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..fd0ccf2 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react'; +import { Link, Route, Routes } from 'react-router-dom'; +import { redirectToLogin, useAuth } from './auth.js'; +import { ClientsPage } from './pages/Clients.js'; +import { ServicesPage } from './pages/Services.js'; +import { OrganizationPage } from './pages/Organization.js'; + +function Layout({ email }: { email: string }) { + return ( +
+

Doc_manager

+ + {email} +
+ ); +} + +function Placeholder({ title }: { title: string }) { + return ( +
+

{title}

+

В разработке. См. план: M2–M7.

+
+ ); +} + +function Forbidden({ email }: { email: string }) { + return ( +
+

Нет доступа

+

+ Аккаунт {email} авторизован в Queo, но не имеет роли в Doc_manager. Попросите + администратора выдать doc_manager permission в auth.queo.ru. +

+
+ ); +} + +export function App() { + const auth = useAuth(); + + useEffect(() => { + if (auth.status === 'unauthenticated') redirectToLogin(); + }, [auth.status]); + + if (auth.status === 'loading' || auth.status === 'unauthenticated') { + return
Проверка доступа…
; + } + if (auth.status === 'forbidden') return ; + + return ( + <> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 0000000..d80b845 --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,74 @@ +import { redirectToLogin } from './auth.js'; + +export class ApiError extends Error { + constructor(public status: number, public code: string, public details?: unknown) { + super(`${status} ${code}`); + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const init: RequestInit = { method, credentials: 'include' }; + if (body !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body); + } + const res = await fetch(path, init); + if (res.status === 401) { + redirectToLogin(); + } + if (res.status === 204) return undefined as T; + const text = await res.text(); + const data = text ? JSON.parse(text) : undefined; + if (!res.ok) { + throw new ApiError(res.status, (data as { error?: string })?.error ?? 'http_error', data); + } + return data as T; +} + +export const api = { + get: (p: string) => request('GET', p), + post: (p: string, body: unknown) => request('POST', p, body), + put: (p: string, body: unknown) => request('PUT', p, body), + del: (p: string) => request('DELETE', p), +}; + +export type Organization = { + id: string; + name: string; + inn: string; + kpp: string | null; + ogrn: string | null; + legalAddress: string | null; + bankName: string | null; + bankBik: string | null; + bankAccount: string | null; + signatoryName: string | null; + signatoryPosition: string | null; +}; + +export type Client = { + id: string; + organizationId: string; + kind: 'ul' | 'ip' | 'fl'; + name: string; + inn: string | null; + kpp: string | null; + address: string | null; + email: string | null; + phone: string | null; + contactPerson: string | null; + requisitesJson: Record | null; + createdAt: string; + updatedAt: string; +}; + +export type Service = { + id: string; + organizationId: string; + name: string; + unit: string; + defaultPriceCents: number; // BigInt сериализуется в number (см. apps/api/src/lib/bigint.ts) + defaultVat: 'none' | 'vat_0' | 'vat_5' | 'vat_7' | 'vat_10' | 'vat_20'; + notes: string | null; + archivedAt: string | null; +}; diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts new file mode 100644 index 0000000..93d7609 --- /dev/null +++ b/apps/web/src/auth.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import type { PermissionRole } from '@doc-manager/shared'; + +export type Me = { + sub: string; + email: string; + groups: string[]; + isSuperuser: boolean; + docPermission: PermissionRole | null; +}; + +const AUTH_LOGIN_URL = import.meta.env.VITE_AUTH_LOGIN_URL ?? 'https://auth.queo.ru/auth/login'; + +export function redirectToLogin(): never { + const returnTo = encodeURIComponent(window.location.href); + window.location.href = `${AUTH_LOGIN_URL}?return_to=${returnTo}`; + throw new Error('redirecting'); +} + +export type AuthState = + | { status: 'loading' } + | { status: 'authenticated'; me: Me } + | { status: 'unauthenticated' } + | { status: 'forbidden'; me: Me }; + +export function useAuth(): AuthState { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + fetch('/api/me', { credentials: 'include' }) + .then(async (r) => { + if (cancelled) return; + if (r.status === 401) { + setState({ status: 'unauthenticated' }); + return; + } + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const me = (await r.json()) as Me; + // Для M1 любой залогиненный пользователь видит шелл; запрет отдельных страниц — позже. + if (me.docPermission == null && !me.isSuperuser) { + setState({ status: 'forbidden', me }); + return; + } + setState({ status: 'authenticated', me }); + }) + .catch((e) => { + if (cancelled) return; + console.error('auth fetch failed', e); + setState({ status: 'unauthenticated' }); + }); + return () => { + cancelled = true; + }; + }, []); + + return state; +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..054fcec --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,109 @@ +import { type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes, useEffect } from 'react'; + +export function Button({ + variant = 'default', + ...props +}: ButtonHTMLAttributes & { variant?: 'default' | 'primary' | 'danger' | 'ghost' }) { + return