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:
admin
2026-05-01 11:08:26 +03:00
parent b28c0463b3
commit 524789facc
15 changed files with 909 additions and 200 deletions
@@ -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;
+27
View File
@@ -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
View File
@@ -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();
},
);
}
+87 -29
View File
@@ -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();
});
}
+20
View File
@@ -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);
});
});
+4
View File
@@ -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);