feat: multi-organization support + bank accounts
- Renamed singular /api/organization → CRUD /api/organizations - New BankAccount table with CRUD under /api/organizations/:orgId/bank-accounts - TochkaCredential gets optional bankAccountId for future per-account bank API config - Active organization stored in cookie dm_org, /api/active-organization GET/POST - activeOrgPlugin resolves req._orgId from cookie (or first non-archived as fallback) - Migration 1_multiorg: BankAccount table + data backfill from legacy Organization.bank* fields - Web: new /companies list + /companies/:id with tabs (Реквизиты, Банки и счета, Интеграции stub) - Web: OrgSwitcher dropdown in header (active org + management link) - Removed nav "Реквизиты", "Банк" — replaced by "Компании" - Per-field error highlighting wired up on new forms Existing organization data backfills cleanly: legacy bank* fields stay readable, but new BankAccount becomes the source of truth going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
-- AlterTable: add shortName, archivedAt to Organization
|
||||
ALTER TABLE "Organization" ADD COLUMN "shortName" TEXT;
|
||||
ALTER TABLE "Organization" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable: BankAccount
|
||||
CREATE TABLE "BankAccount" (
|
||||
"id" UUID NOT NULL,
|
||||
"organizationId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"bankName" TEXT,
|
||||
"bankBik" TEXT,
|
||||
"accountNumber" TEXT,
|
||||
"corrAccount" TEXT,
|
||||
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
|
||||
"archivedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BankAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "BankAccount_organizationId_idx" ON "BankAccount"("organizationId");
|
||||
|
||||
ALTER TABLE "BankAccount" ADD CONSTRAINT "BankAccount_organizationId_fkey"
|
||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable: add bankAccountId on TochkaCredential
|
||||
ALTER TABLE "TochkaCredential" ADD COLUMN "bankAccountId" UUID;
|
||||
ALTER TABLE "TochkaCredential" ADD CONSTRAINT "TochkaCredential_bankAccountId_fkey"
|
||||
FOREIGN KEY ("bankAccountId") REFERENCES "BankAccount"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Data migration: для каждой организации с заполненным bank* — создать запись BankAccount(isPrimary=true)
|
||||
INSERT INTO "BankAccount" ("id", "organizationId", "name", "bankName", "bankBik", "accountNumber", "currency", "isPrimary", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
o."id",
|
||||
'Основной счёт',
|
||||
o."bankName",
|
||||
o."bankBik",
|
||||
o."bankAccount",
|
||||
'RUB',
|
||||
true,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM "Organization" o
|
||||
WHERE o."bankName" IS NOT NULL OR o."bankBik" IS NOT NULL OR o."bankAccount" IS NOT NULL;
|
||||
@@ -58,15 +58,19 @@ enum PaymentKind {
|
||||
model Organization {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
shortName String? // короткое имя для бейджей в UI / селектора
|
||||
inn String
|
||||
kpp String?
|
||||
ogrn String?
|
||||
legalAddress String?
|
||||
// Поля bankName/bankBik/bankAccount устарели после введения BankAccount.
|
||||
// Оставлены для обратной совместимости с уже сохранёнными данными.
|
||||
bankName String?
|
||||
bankBik String?
|
||||
bankAccount String?
|
||||
signatoryName String?
|
||||
signatoryPosition String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -77,6 +81,27 @@ model Organization {
|
||||
payments Payment[]
|
||||
tochkaCredentials TochkaCredential[]
|
||||
auditLog AuditLog[]
|
||||
bankAccounts BankAccount[]
|
||||
}
|
||||
|
||||
model BankAccount {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
name String // отображаемое имя, напр. "Точка — основной"
|
||||
bankName String?
|
||||
bankBik String?
|
||||
accountNumber String? // р/счёт (20 цифр)
|
||||
corrAccount String? // к/счёт (20 цифр)
|
||||
currency String @default("RUB")
|
||||
isPrimary Boolean @default(false)
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tochkaCredentials TochkaCredential[]
|
||||
|
||||
@@index([organizationId])
|
||||
}
|
||||
|
||||
model Client {
|
||||
@@ -209,6 +234,8 @@ model TochkaCredential {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @db.Uuid
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
bankAccountId String? @db.Uuid
|
||||
bankAccount BankAccount? @relation(fields: [bankAccountId], references: [id])
|
||||
environment TochkaEnv
|
||||
// AES-256-GCM ciphertext (iv|tag|ct), base64
|
||||
jwtEncrypted String
|
||||
|
||||
+54
-5
@@ -1,11 +1,60 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
export const ACTIVE_ORG_COOKIE = 'dm_org';
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
path: '/',
|
||||
sameSite: 'lax' as const,
|
||||
httpOnly: false,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
};
|
||||
|
||||
/**
|
||||
* Возвращает organization_id, в контексте которого работает запрос.
|
||||
* Single-tenant v1: всегда DEFAULT_ORGANIZATION_ID.
|
||||
* При переходе к multi-tenant заменим на маппинг из req.user.permissions / групп / отдельной таблицы membership.
|
||||
* Возвращает organization_id для текущего запроса.
|
||||
* Алгоритм:
|
||||
* 1. Читаем cookie dm_org. Если есть — проверяем, что такая организация ЕСТЬ и не архивная.
|
||||
* 2. Иначе берём первую неархивную из БД.
|
||||
* 3. Иначе — DEFAULT_ORGANIZATION_ID из env (для новых установок без записей).
|
||||
*
|
||||
* Кэшировать на запросе (декорирован в server.ts) — иначе хождение в БД на каждый роут.
|
||||
*/
|
||||
export function getOrganizationId(_req: FastifyRequest): string {
|
||||
export async function resolveOrganizationId(req: FastifyRequest): Promise<string> {
|
||||
const cookieId = req.cookies?.[ACTIVE_ORG_COOKIE];
|
||||
if (cookieId) {
|
||||
const found = await prisma.organization.findFirst({
|
||||
where: { id: cookieId, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found) return found.id;
|
||||
}
|
||||
const first = await prisma.organization.findFirst({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (first) return first.id;
|
||||
return env.DEFAULT_ORGANIZATION_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Старая функция для роутов, которые ещё не переехали на async.
|
||||
* Использовать только если из контекста уже известно, что cookie проверена выше.
|
||||
* Безопасно после регистрации `resolveOrgPlugin` ниже.
|
||||
*/
|
||||
export function getOrganizationId(req: FastifyRequest): string {
|
||||
const cached = (req as { _orgId?: string })._orgId;
|
||||
if (cached) return cached;
|
||||
// На случай раннего вызова без middleware — fallback (не должно происходить).
|
||||
return env.DEFAULT_ORGANIZATION_ID;
|
||||
}
|
||||
|
||||
export function setActiveOrgCookie(reply: FastifyReply, id: string) {
|
||||
reply.setCookie(ACTIVE_ORG_COOKIE, id, COOKIE_OPTS);
|
||||
}
|
||||
|
||||
export function clearActiveOrgCookie(reply: FastifyReply) {
|
||||
reply.clearCookie(ACTIVE_ORG_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../db.js';
|
||||
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||
|
||||
const BankAccountUpsert = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
bankName: optionalText(500),
|
||||
bankBik: optionalRegex(/^\d{9}$/),
|
||||
accountNumber: optionalRegex(/^\d{20}$/),
|
||||
corrAccount: optionalRegex(/^\d{20}$/),
|
||||
currency: z.string().length(3).default('RUB'),
|
||||
isPrimary: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function bankAccountsRoutes(app: FastifyInstance) {
|
||||
// ---- список под organization ----
|
||||
app.get(
|
||||
'/api/organizations/:orgId/bank-accounts',
|
||||
{ preHandler: app.requireDocPermission('viewer') },
|
||||
async (req, reply) => {
|
||||
const { orgId } = req.params as { orgId: string };
|
||||
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'organization_not_found' });
|
||||
return;
|
||||
}
|
||||
const items = await prisma.bankAccount.findMany({
|
||||
where: { organizationId: orgId, archivedAt: null },
|
||||
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return { items };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- create ----
|
||||
app.post(
|
||||
'/api/organizations/:orgId/bank-accounts',
|
||||
{ preHandler: app.requireDocPermission('user') },
|
||||
async (req, reply) => {
|
||||
const { orgId } = req.params as { orgId: string };
|
||||
const parsed = BankAccountUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const org = await prisma.organization.findFirst({ where: { id: orgId, archivedAt: null } });
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'organization_not_found' });
|
||||
return;
|
||||
}
|
||||
// Только один primary на организацию
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
if (parsed.data.isPrimary) {
|
||||
await tx.bankAccount.updateMany({
|
||||
where: { organizationId: orgId, isPrimary: true },
|
||||
data: { isPrimary: false },
|
||||
});
|
||||
}
|
||||
return tx.bankAccount.create({ data: { ...parsed.data, organizationId: orgId } });
|
||||
});
|
||||
reply.code(201).send(created);
|
||||
},
|
||||
);
|
||||
|
||||
// ---- update ----
|
||||
app.put(
|
||||
'/api/organizations/:orgId/bank-accounts/:id',
|
||||
{ preHandler: app.requireDocPermission('user') },
|
||||
async (req, reply) => {
|
||||
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||
const parsed = BankAccountUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.bankAccount.findFirst({
|
||||
where: { id, organizationId: orgId, archivedAt: null },
|
||||
});
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (parsed.data.isPrimary) {
|
||||
await tx.bankAccount.updateMany({
|
||||
where: { organizationId: orgId, isPrimary: true, id: { not: id } },
|
||||
data: { isPrimary: false },
|
||||
});
|
||||
}
|
||||
return tx.bankAccount.update({ where: { id }, data: parsed.data });
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
|
||||
// ---- archive ----
|
||||
app.delete(
|
||||
'/api/organizations/:orgId/bank-accounts/:id',
|
||||
{ preHandler: app.requireDocPermission('user') },
|
||||
async (req, reply) => {
|
||||
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||
const existing = await prisma.bankAccount.findFirst({
|
||||
where: { id, organizationId: orgId, archivedAt: null },
|
||||
});
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
await prisma.bankAccount.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||
reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,112 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../db.js';
|
||||
import { getOrganizationId } from '../../lib/org.js';
|
||||
import { ACTIVE_ORG_COOKIE, resolveOrganizationId, setActiveOrgCookie } from '../../lib/org.js';
|
||||
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||
|
||||
const OrgUpdate = z.object({
|
||||
const OrgUpsert = z.object({
|
||||
name: z.string().min(1).max(500),
|
||||
shortName: optionalText(100),
|
||||
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
||||
kpp: optionalRegex(/^\d{9}$/),
|
||||
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
|
||||
legalAddress: optionalText(1000),
|
||||
bankName: optionalText(500),
|
||||
bankBik: optionalRegex(/^\d{9}$/),
|
||||
bankAccount: optionalRegex(/^\d{20}$/),
|
||||
signatoryName: optionalText(500),
|
||||
signatoryPosition: optionalText(500),
|
||||
});
|
||||
|
||||
const SetActive = z.object({ id: z.string().uuid() });
|
||||
|
||||
export async function organizationsRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
'/api/organization',
|
||||
// ---- список ----
|
||||
app.get('/api/organizations', { preHandler: app.requireDocPermission('viewer') }, async () => {
|
||||
const items = await prisma.organization.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return { items };
|
||||
});
|
||||
|
||||
// ---- активная (id + объект) ----
|
||||
app.get('/api/active-organization', { preHandler: app.requireDocPermission('viewer') }, async (req) => {
|
||||
const id = await resolveOrganizationId(req);
|
||||
const org = await prisma.organization.findUnique({ where: { id } });
|
||||
return { id, organization: org };
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/active-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);
|
||||
const parsed = SetActive.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 },
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { id: parsed.data.id, archivedAt: null },
|
||||
});
|
||||
return org;
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
setActiveOrgCookie(reply, org.id);
|
||||
return { id: org.id, organization: org };
|
||||
},
|
||||
);
|
||||
|
||||
// ---- одна организация ----
|
||||
app.get('/api/organizations/:id', { preHandler: app.requireDocPermission('viewer') }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const org = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||
if (!org) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return org;
|
||||
});
|
||||
|
||||
// ---- create ----
|
||||
app.post('/api/organizations', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||
const parsed = OrgUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const created = await prisma.organization.create({ data: parsed.data });
|
||||
// если активной не было — сделать новую активной
|
||||
if (!req.cookies?.[ACTIVE_ORG_COOKIE]) {
|
||||
setActiveOrgCookie(reply, created.id);
|
||||
}
|
||||
reply.code(201).send(created);
|
||||
});
|
||||
|
||||
// ---- update ----
|
||||
app.put('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const parsed = OrgUpsert.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
return prisma.organization.update({ where: { id }, data: parsed.data });
|
||||
});
|
||||
|
||||
// ---- archive ----
|
||||
app.delete('/api/organizations/:id', { preHandler: app.requireDocPermission('admin') }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await prisma.organization.findFirst({ where: { id, archivedAt: null } });
|
||||
if (!existing) {
|
||||
reply.code(404).send({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
// Не удаляем физически — есть FK от документов, клиентов, услуг; делаем soft-delete.
|
||||
await prisma.organization.update({ where: { id }, data: { archivedAt: new Date() } });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { resolveOrganizationId } from '../lib/org.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
_orgId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолвит активную организацию для каждого запроса и кладёт в req._orgId.
|
||||
* Запускается ПОСЛЕ requireAuth, поэтому сидит в onRequest hook only-after-auth.
|
||||
*/
|
||||
export default fp(async function activeOrgPlugin(app) {
|
||||
app.addHook('preHandler', async (req) => {
|
||||
if (!req.user) return; // не аутентифицирован — оставляем как есть
|
||||
if (req._orgId) return;
|
||||
req._orgId = await resolveOrganizationId(req);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import authPlugin from './plugins/auth.js';
|
||||
import { healthRoutes } from './routes/health.js';
|
||||
import { meRoutes } from './routes/me.js';
|
||||
import { organizationsRoutes } from './modules/organizations/routes.js';
|
||||
import { bankAccountsRoutes } from './modules/bank-accounts/routes.js';
|
||||
import { clientsRoutes } from './modules/clients/routes.js';
|
||||
import { servicesRoutes } from './modules/services/routes.js';
|
||||
import { documentsRoutes } from './modules/documents/routes.js';
|
||||
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
|
||||
import { templatesRoutes } from './modules/templates/routes.js';
|
||||
import { shutdownBrowser } from './modules/documents/pdf.js';
|
||||
import activeOrgPlugin from './plugins/activeOrg.js';
|
||||
|
||||
async function main() {
|
||||
const loggerOptions =
|
||||
@@ -36,10 +38,12 @@ async function main() {
|
||||
});
|
||||
await app.register(cookie);
|
||||
await app.register(authPlugin);
|
||||
await app.register(activeOrgPlugin);
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(meRoutes);
|
||||
await app.register(organizationsRoutes);
|
||||
await app.register(bankAccountsRoutes);
|
||||
await app.register(clientsRoutes);
|
||||
await app.register(servicesRoutes);
|
||||
await app.register(documentsRoutes);
|
||||
|
||||
Reference in New Issue
Block a user