4553f63deb
- 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>
112 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
}
|