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:
admin
2026-04-30 21:24:26 +03:00
commit 4553f63deb
52 changed files with 7110 additions and 0 deletions
+36
View File
@@ -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
+37
View File
@@ -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"
+250
View File
@@ -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])
}
+32
View File
@@ -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();
});
+129
View File
@@ -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');
});
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
+41
View File
@@ -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);
}
+18
View File
@@ -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 {};
+11
View File
@@ -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;
}
+111
View File
@@ -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;
},
);
}
+121
View File
@@ -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 } });
});
}
+84
View File
@@ -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' });
}
};
});
});
+16
View File
@@ -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' });
}
});
}
+15
View File
@@ -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,
};
});
}
+55
View File
@@ -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);
});
+14
View File
@@ -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/**/*"]
}