/** * Прямой доступ к Postgres для preconditions (TRUNCATE) и data-checks * (SELECT). Работает через docker exec на контейнер food-market-postgres, * так что не зависит от того, проброшен ли порт наружу. */ import { execFileSync } from 'node:child_process' export function psql(sql: string): string { // Использую pgcli/psql внутри контейнера. -tA = bare output без заголовков. return execFileSync( 'docker', ['exec', '-i', 'food-market-postgres', 'psql', '-U', 'food_market', '-d', 'food_market', '-tAv', 'ON_ERROR_STOP=1', '-c', sql], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 }, ) } export function psqlRows(sql: string): string[][] { const out = psql(sql).trim() if (!out) return [] return out.split('\n').map((line) => line.split('|')) } /** * TRUNCATE для preconditions full-cycle сценария. * * Реестр товаров и системные справочники — не трогаем. Платформенные настройки, * миграции и OpenIddict — не трогаем. Identity SuperAdmin (admin@food-market.local) * сохраняем; всех остальных AspNetUsers/UserRoles/Employees/Organizations * чистим. */ export function resetTenantData(): void { // Список таблиц получаем из информационной схемы — устойчиво к новым доменным // сущностям. Исключаем «keep-list» по подстроке (lowercase). Ниже — порядок // вырезания: сначала зависимые таблицы, потом ссылочные. const KEEP = new Set([ // Реестр товаров 'products', 'product_groups', 'units_of_measure', 'product_packagings', 'product_barcodes', 'product_prices', 'product_images', // Справочники 'countries', 'currencies', 'price_types', 'employee_roles', // Инфраструктура '__EFMigrationsHistory', 'platform_settings', 'system_settings', ]) const KEEP_PREFIXES = ['OpenIddict'] const all = psqlRows(`SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename`) .map((r) => r[0]) .filter(Boolean) // Identity-табицы (AspNet*, users, roles) обрабатываем отдельно с фильтром // SuperAdmin — нельзя просто TRUNCATE. const IDENTITY = ['AspNetUserClaims', 'AspNetUserLogins', 'AspNetUserTokens', 'AspNetUserRoles', 'AspNetRoleClaims', 'users', 'roles'] const isIdentity = (t: string) => IDENTITY.includes(t) const tenantTables = all .filter((t) => !KEEP.has(t)) .filter((t) => !KEEP_PREFIXES.some((p) => t.startsWith(p))) .filter((t) => !isIdentity(t)) if (tenantTables.length > 0) { // Bulk TRUNCATE с CASCADE — порядок не важен. const list = tenantTables.map((t) => `"${t}"`).join(', ') psql(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`) } // Identity-зачистка: оставляем только SuperAdmin'а. psql(` DELETE FROM "AspNetUserRoles" ur USING users u WHERE ur."UserId" = u."Id" AND lower(u."Email") <> 'admin@food-market.local'; DELETE FROM "AspNetUserClaims" uc USING users u WHERE uc."UserId" = u."Id" AND lower(u."Email") <> 'admin@food-market.local'; DELETE FROM "AspNetUserLogins" ul USING users u WHERE ul."UserId" = u."Id" AND lower(u."Email") <> 'admin@food-market.local'; DELETE FROM "AspNetUserTokens" ut USING users u WHERE ut."UserId" = u."Id" AND lower(u."Email") <> 'admin@food-market.local'; DELETE FROM users WHERE lower("Email") <> 'admin@food-market.local'; -- SuperAdmin отвязываем от любой удалённой org. UPDATE users SET "OrganizationId" = NULL, "IsActive" = true WHERE lower("Email") = 'admin@food-market.local'; -- OpenIddict tokens деактивируем (refresh-tokens чужих юзеров). UPDATE "OpenIddictTokens" SET "Status" = 'revoked' WHERE "Status" = 'valid'; `) } export function countRows(table: string, where = ''): number { const sql = `SELECT count(*) FROM "${table}" ${where ? 'WHERE ' + where : ''};` const out = psql(sql).trim() return Number(out) || 0 }