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:
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user