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
+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 } });
});
}