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
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@doc-manager/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./blocks": "./src/blocks/schema.ts",
"./tochka": "./src/tochka/dto.ts",
"./auth": "./src/auth/types.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
// Контракт JWT-payload, выдаваемого auth.queo.ru.
// Источник: C:\project\Auth_server\backend\src\services\tokens.js
export const PermissionRole = z.enum(['viewer', 'user', 'admin']);
export type PermissionRole = z.infer<typeof PermissionRole>;
export const AuthPayload = z.object({
sub: z.string().uuid(),
email: z.string().email(),
groups: z.array(z.string()).default([]),
permissions: z.record(PermissionRole).default({}),
isSuperuser: z.boolean().default(false),
iat: z.number().optional(),
exp: z.number().optional(),
iss: z.literal('https://auth.queo.ru').optional(),
aud: z.union([z.literal('queo.ru'), z.array(z.string())]).optional(),
});
export type AuthPayload = z.infer<typeof AuthPayload>;
export const DOC_MANAGER_RESOURCE = 'doc_manager' as const;
// Иерархия ролей: admin ⊃ user ⊃ viewer
export function hasDocPermission(payload: AuthPayload, required: PermissionRole): boolean {
if (payload.isSuperuser) return true;
const role = payload.permissions[DOC_MANAGER_RESOURCE];
if (!role) return false;
const order: PermissionRole[] = ['viewer', 'user', 'admin'];
return order.indexOf(role) >= order.indexOf(required);
}
+115
View File
@@ -0,0 +1,115 @@
import { z } from 'zod';
// TipTap-совместимое JSON-дерево rich-текста.
// Не валидируем глубоко — TipTap отдаёт собственный формат, нам важно только сохранить и доставить.
export const RichTextSchema: z.ZodType<unknown> = z.object({
type: z.string(),
content: z.array(z.lazy((): z.ZodType<unknown> => RichTextSchema)).optional(),
text: z.string().optional(),
marks: z.array(z.object({ type: z.string(), attrs: z.record(z.unknown()).optional() })).optional(),
attrs: z.record(z.unknown()).optional(),
});
export type RichText = z.infer<typeof RichTextSchema>;
// VAT-ставки актуальные на 2026-04 (включая 5% и 7% для УСН-плательщиков).
// Имена совпадают с TS-клиентом Prisma (в БД хранится как '0','5',... через @map).
// При отправке в Tochka API используется отдельный маппер (см. packages/shared/src/tochka/dto.ts).
export const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
export type VatRate = z.infer<typeof VatRate>;
export const VAT_LABEL: Record<VatRate, string> = {
none: 'Без НДС',
vat_0: '0%',
vat_5: '5%',
vat_7: '7%',
vat_10: '10%',
vat_20: '20%',
};
export const VAT_PERCENT: Record<VatRate, number> = {
none: 0,
vat_0: 0,
vat_5: 5,
vat_7: 7,
vat_10: 10,
vat_20: 20,
};
const blockBase = z.object({ id: z.string().min(1) });
export const HeadingBlock = blockBase.extend({
type: z.literal('heading'),
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
text: RichTextSchema,
});
export const ParagraphBlock = blockBase.extend({
type: z.literal('paragraph'),
text: RichTextSchema,
});
export const PartyBind = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('client'), clientId: z.string().uuid().optional() }),
z.object({ kind: z.literal('self') }),
]);
export const PartyBlock = blockBase.extend({
type: z.literal('party'),
role: z.enum(['executor', 'customer']),
bind: PartyBind,
});
export const ServicesTableBlock = blockBase.extend({
type: z.literal('services_table'),
columns: z.array(z.enum(['name', 'qty', 'unit', 'price', 'vat', 'sum'])).min(1),
// строки услуг живут в таблице document_lines; здесь только ссылки на строки этого документа
lines: z.array(z.object({ lineId: z.string().uuid() })),
});
export const TotalsBlock = blockBase.extend({
type: z.literal('totals'),
showVat: z.boolean(),
showInWords: z.boolean(),
});
export const TermsBlock = blockBase.extend({
type: z.literal('terms'),
text: RichTextSchema,
});
export const SignaturesBlock = blockBase.extend({
type: z.literal('signatures'),
sides: z.array(z.enum(['executor', 'customer'])).min(1),
});
export const CustomTextBlock = blockBase.extend({
type: z.literal('custom_text'),
text: RichTextSchema,
});
export const PageBreakBlock = blockBase.extend({
type: z.literal('page_break'),
});
export const Block = z.discriminatedUnion('type', [
HeadingBlock,
ParagraphBlock,
PartyBlock,
ServicesTableBlock,
TotalsBlock,
TermsBlock,
SignaturesBlock,
CustomTextBlock,
PageBreakBlock,
]);
export type Block = z.infer<typeof Block>;
export const DocBody = z.object({
version: z.literal(1),
blocks: z.array(Block),
vars: z.record(z.unknown()).default({}),
});
export type DocBody = z.infer<typeof DocBody>;
export const emptyDocBody = (): DocBody => ({ version: 1, blocks: [], vars: {} });
+3
View File
@@ -0,0 +1,3 @@
export * from './blocks/schema.js';
export * from './tochka/dto.js';
export * from './auth/types.js';
+74
View File
@@ -0,0 +1,74 @@
import { z } from 'zod';
export const TochkaEnv = z.enum(['sandbox', 'prod']);
export type TochkaEnv = z.infer<typeof TochkaEnv>;
export const TochkaBaseUrl: Record<TochkaEnv, string> = {
sandbox: 'https://enter.tochka.com/sandbox/v2',
prod: 'https://enter.tochka.com/uapi',
};
// Минимальный набор полей для M4. Расширим, когда возьмёмся за акты/УПД.
export const TochkaSecondSide = z.object({
inn: z.string().min(10).max(12),
kpp: z.string().length(9).optional(),
legalAddress: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional(),
contactPhone: z.string().optional(),
});
export type TochkaSecondSide = z.infer<typeof TochkaSecondSide>;
export const TochkaInvoiceLine = z.object({
name: z.string(),
measure: z.string(), // ед. измерения, напр. "шт", "час"
amount: z.number().positive(), // количество
price: z.number().nonnegative(), // цена за единицу в рублях (Точка ждёт число, не копейки)
vatRate: z.enum(['none', '0', '5', '7', '10', '20']),
});
export type TochkaInvoiceLine = z.infer<typeof TochkaInvoiceLine>;
export const TochkaCreateInvoiceRequest = z.object({
Data: z.object({
accountId: z.string(),
customerCode: z.string(),
documentNumber: z.string().optional(),
documentDate: z.string().optional(), // YYYY-MM-DD
secondSide: TochkaSecondSide,
items: z.array(TochkaInvoiceLine).min(1),
paymentExpiryDate: z.string().optional(),
}),
});
export type TochkaCreateInvoiceRequest = z.infer<typeof TochkaCreateInvoiceRequest>;
export const TochkaCreateInvoiceResponse = z.object({
Data: z.object({
documentId: z.string(),
}),
});
// Webhook payloads (минимум, что мы используем для статуса оплаты).
export const TochkaWebhookEvent = z.object({
webhookType: z.enum([
'incomingPayment',
'incomingSbpPayment',
'outgoingPayment',
'incomingSbpB2BPayment',
]),
paymentId: z.string(),
amount: z.number().optional(),
payerInn: z.string().optional(),
payerName: z.string().optional(),
purpose: z.string().optional(),
paidAt: z.string().optional(),
});
export type TochkaWebhookEvent = z.infer<typeof TochkaWebhookEvent>;
export const TochkaPaymentStatus = z.enum([
'awaiting_payment',
'partially_paid',
'paid',
'overdue',
'cancelled',
]);
export type TochkaPaymentStatus = z.infer<typeof TochkaPaymentStatus>;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"noEmit": true
},
"include": ["src/**/*"]
}