Files
doc-manager/apps/api/src/modules/clients/routes.ts
T
admin 4553f63deb 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>
2026-04-30 21:24:26 +03:00

112 lines
3.9 KiB
TypeScript

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