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) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<typeof spawn> | 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');
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
@@ -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<typeof EnvSchema>;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
});
|
||||
}
|
||||
@@ -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<void>;
|
||||
requireDocPermission: (
|
||||
level: PermissionRole,
|
||||
) => (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
// Один 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<JWTPayload>(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' });
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user