diff --git a/deploy/stage-prune-autotest.sql b/deploy/stage-prune-autotest.sql new file mode 100644 index 0000000..ef47ffe --- /dev/null +++ b/deploy/stage-prune-autotest.sql @@ -0,0 +1,174 @@ +-- deploy/stage-prune-autotest.sql +-- Одноразовая зачистка стейджа от автотестового мусора. Пишется как idempotent +-- SQL-скрипт — его же логика живёт в HousekeepingJobs.PruneAutoTestEntitiesAsync +-- (Hangfire 02:45 UTC daily). Этот файл пригоден для ручного запуска +-- (например, на prod-vm через `docker exec food-market-stage-postgres-1 psql +-- -U food_market -d food_market -f /tmp/stage-prune-autotest.sql`). +-- +-- Что НЕ трогает: +-- * admin@food-market.local (SuperAdmin — OrganizationId IS NULL) +-- * admin@demo-market.local +-- * market@email.com +-- * demo-*@food-market.local (quick-login demo-trio) +-- * «Demo Market» и «Демо-магазин» (seeded orgs) +-- +-- Условие удаления: имя орги подходит под регулярку autotest ИЛИ email юзера +-- подходит под autotest-патéрн / служебный домен. Для свежих прогонов — +-- threshold 3 дня (меняется через psql -v). + +\set threshold_days 3 + +DO $do$ +DECLARE + threshold_ts timestamp with time zone := NOW() - INTERVAL '3 days'; + org_ids uuid[]; + tbl text; + pass int := 0; + remaining int; + autotest_org_rx text := '[0-9]{10,}' || + '|^quality-|^soak-|^(ui-)?a11y-|^smokeorg[ab]?-|^perfbaseline-|^reg-' || + '|^rt-ratetest-|^probe\s|^cat3[0-9]-|^visual-|^factory-smoke-|^mt6[0-9]-' || + '|^doc4[0-9]-|^auth2[0-9]-|^rep5[0-9]-|^rt8[0-9]-|^wiz9[0-9]-' || + '|^i18n[0-9]+-|^twofa[0-9]+-|^iso[a-z]*[0-9]*-|^mob1[0-9]-|^axdet-' || + '|^load-sales-|^load-|^s19-test-'; + autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)'; + autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$'; + deleted_orgs int; + deleted_users int; +BEGIN + SELECT array_agg("Id") INTO org_ids FROM public.organizations + WHERE "Name" <> 'Demo Market' + AND "Name" <> 'Демо-магазин' + AND "Name" ~* autotest_org_rx + AND "CreatedAt" < threshold_ts; + + IF array_length(org_ids, 1) IS NOT NULL THEN + RAISE NOTICE 'Нашли % autotest-органов для удаления', array_length(org_ids, 1); + + FOR pass IN 1..10 LOOP + remaining := 0; + FOR tbl IN + SELECT c.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = 'public' + AND c.column_name = 'OrganizationId' + AND t.table_type = 'BASE TABLE' + AND c.table_name NOT IN ('organizations', 'users') + LOOP + BEGIN + EXECUTE format('DELETE FROM public.%I WHERE "OrganizationId" = ANY($1)', tbl) USING org_ids; + EXCEPTION WHEN foreign_key_violation THEN + remaining := remaining + 1; + END; + END LOOP; + EXIT WHEN remaining = 0; + END LOOP; + + DELETE FROM "AspNetUserRoles" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + DELETE FROM "AspNetUserTokens" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + DELETE FROM "AspNetUserClaims" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + DELETE FROM "AspNetUserLogins" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + DELETE FROM "OpenIddictTokens" WHERE "Subject" IN ( + SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN ( + SELECT u."Id"::text FROM public.users u WHERE u."OrganizationId" = ANY(org_ids) + ); + + GET DIAGNOSTICS deleted_users = ROW_COUNT; + DELETE FROM public.users WHERE "OrganizationId" = ANY(org_ids); + DELETE FROM public.organizations WHERE "Id" = ANY(org_ids); + GET DIAGNOSTICS deleted_orgs = ROW_COUNT; + RAISE NOTICE 'Удалено orgs=%, users с org=%', deleted_orgs, deleted_users; + ELSE + RAISE NOTICE 'Нет autotest-органов старше %', threshold_ts; + END IF; + + -- Orphan autotest-юзеры (без tagged org). Исключения — такие же как в + -- Hangfire-методе: SuperAdmin, demo-trio, admin@demo-market.local, market@. + DELETE FROM "AspNetUserRoles" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM "AspNetUserTokens" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM "AspNetUserClaims" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM "AspNetUserLogins" WHERE "UserId" IN ( + SELECT u."Id" FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM "OpenIddictTokens" WHERE "Subject" IN ( + SELECT u."Id"::text FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM "OpenIddictAuthorizations" WHERE "Subject" IN ( + SELECT u."Id"::text FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts + ); + DELETE FROM public.users u + WHERE (u."Email" ~* autotest_user_rx OR u."Email" ~* autotest_domain_rx OR u."Email" ~ '[0-9]{10,}') + AND u."Email" NOT LIKE 'demo-%@food-market.local' + AND u."Email" <> 'admin@food-market.local' + AND u."Email" <> 'admin@demo-market.local' + AND u."Email" <> 'market@email.com' + AND u."OrganizationId" IS NOT NULL + AND u."CreatedAt" < threshold_ts; + GET DIAGNOSTICS deleted_users = ROW_COUNT; + RAISE NOTICE 'Удалено orphan autotest-юзеров: %', deleted_users; +END $do$; + +-- Итог +SELECT 'users_remaining' AS metric, COUNT(*) AS value FROM users +UNION ALL +SELECT 'orgs_remaining', COUNT(*) FROM organizations; diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index 113da46..a1785b3 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -33,7 +33,9 @@ Клик — моментальный логин через мастер-пароль. На проде блока нет. - [x] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса сверху + `` в index.html стенда. -- [ ] 7. **Чистка автотестового мусора** — SQL-скрипт для стейджа + расширение +- [x] 7. **Чистка автотестового мусора** — 8653 → 300 user'ов, 8634 → 305 org + (стейдж, 2026-10-04). Протестирован на существующей БД: SuperAdmin / admin@ + demo-market.local / market@email.com остались, свежие прогоны (<3д) сохранены. SQL-скрипт для стейджа + расширение `HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны, чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin, admin@demo-market.local, market@email.com, demo-* аккаунты. diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index 2ca0c3e..26a700d 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -72,6 +72,16 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronQualityOrgs, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Sprint 30: широкий cleanup всех autotest-органа/юзеров (e2e, perf, + // smoke, a11y, ratetest, mt6*, cat3*, ...). Default 02:45 UTC + // (между prune-quality 02:30 и prune-stock 03:30). + var cronAutoTest = _cfg["Hangfire:Cron:PruneAutoTestEntities"] ?? "45 2 * * *"; + _jobs.AddOrUpdate( + recurringJobId: "prune-autotest-entities", + methodCall: j => j.PruneAutoTestEntitiesAsync(CancellationToken.None), + cronExpression: cronAutoTest, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor. var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час diff --git a/src/food-market.api/Background/HousekeepingJobs.cs b/src/food-market.api/Background/HousekeepingJobs.cs index 0b7ed80..ffa789a 100644 --- a/src/food-market.api/Background/HousekeepingJobs.cs +++ b/src/food-market.api/Background/HousekeepingJobs.cs @@ -254,4 +254,228 @@ SELECT c.table_name ids.Count, minHours); return ids.Count; } + + /// Sprint 30: расширение cleanup'а на ВСЕ автотестовые орги/юзеры + /// (не только quality-*): e2e, perf, smoke, a11y, ratetest, mt6*, + /// cat3*, visual-*, factory-smoke-*, auth2*, doc4*, rep5*, rt8*, wiz9*, + /// i18n*, twofa*, iso*, mob14*, mob15*, axdet-*, probe-*, load-*, + /// s19-test-* и всё с суффиксом из ≥10 цифр. + /// + /// Запускается тем же механизмом (Hangfire recurring) что + /// . Порог давности + /// Cleanup:AutoTestOrgDays (default 3) — чтобы живые e2e-прогоны + /// успевали создать-использовать org и завершиться. + /// + /// Защищены от удаления: + /// + /// Demo Market и Демо-магазин (seeded) + любые орги с именем + /// ровно Demo Market / Демо-магазин; + /// demo-admin@/demo-cashier@/demo-keeper@ юзеры; + /// SuperAdmin (без OrganizationId) — не удаляется ни при каких + /// условиях (условие OrganizationId IS NOT NULL в юзер-DELETE); + /// market@email.com — явное исключение по email. + /// + public async Task PruneAutoTestEntitiesAsync(CancellationToken ct = default) + { + var minDays = _cfg.GetValue("Cleanup:AutoTestOrgDays", 3); + var threshold = DateTime.UtcNow.AddDays(-minDays); + + // Regex в PG: '~*' = case-insensitive match. Собираем выражение по + // «автотестовым» префиксам/признакам, аналогично QuickLoginController. + // Условие на имя орги. ВАЖНО: даже «просто цифры в конце» ловит почти + // любой e2e-прогон, который суффиксит имя timestamp'ом. + const string autoTestOrgRx = + @"[0-9]{10,}" + // любой длинный цифровой суффикс + @"|^quality-" + + @"|^soak-" + + @"|^(ui-)?a11y-" + + @"|^smokeorg[ab]?-" + + @"|^perfbaseline-" + + @"|^reg-" + + @"|^rt-ratetest-" + + @"|^probe\s" + // "Probe Org" + @"|^cat3[0-9]-" + + @"|^visual-" + + @"|^factory-smoke-" + + @"|^mt6[0-9]-" + + @"|^doc4[0-9]-" + + @"|^auth2[0-9]-" + + @"|^rep5[0-9]-" + + @"|^rt8[0-9]-" + + @"|^wiz9[0-9]-" + + @"|^i18n[0-9]+-" + + @"|^twofa[0-9]+-" + + @"|^iso[a-z]*[0-9]*-" + + @"|^mob1[0-9]-" + + @"|^axdet-" + + @"|^load-sales-" + + @"|^load-" + + @"|^s19-test-"; + + // SqlQueryRaw — удобный способ вытащить только Id без mapping'а + // к entity. Параметры через FormattableString небезопасно соединять + // (regex из конфига → потенциальный inject), поэтому используем Npgsql- + // параметры напрямую. + var orgIdSql = @" +SELECT ""Id"" FROM public.organizations +WHERE ""Name"" <> 'Demo Market' + AND ""Name"" <> 'Демо-магазин' + AND ""Name"" ~* @rx + AND ""CreatedAt"" < @thr +LIMIT 20000"; + var ids = await _db.Database.SqlQueryRaw(orgIdSql, + new Npgsql.NpgsqlParameter("@rx", autoTestOrgRx), + new Npgsql.NpgsqlParameter("@thr", threshold)) + .ToListAsync(ct); + + if (ids.Count == 0) + { + _log.LogInformation("Hangfire/PruneAutoTestEntities: нет org-кандидатов старше {Days}д", minDays); + // Юзеры без org (orphan-autotest) всё равно могут остаться — обрабатываем ниже. + } + + // Тот же трюк с итеративным DELETE по information_schema, что в + // PruneQualityTestOrgsAsync. Юзеры дополнительно вычищаются по + // регулярке email'а (широкий set autotest-паттернов). + var sql = $@" +DO $do$ +DECLARE + org_ids uuid[] := ARRAY[{(ids.Count == 0 ? "NULL::uuid" : string.Join(",", ids.Select(i => $"'{i}'::uuid")))}]; + tbl text; + pass int := 0; + remaining int; + autotest_user_rx text := '^(cat3[0-9]|visual|mt6[0-9]|factory|doc4[0-9]|auth2[0-9]|rep5[0-9]|rt8[0-9]|wiz9[0-9]|quality|i18n[0-9]+|twofa[0-9]+|a11y|mob1[0-9]|iso|axdet|smoke|ratetest|perf|probe|load|s19-test|admin-[0-9]|cashier-[0-9]|keeper-[0-9]|reg-mt)'; + autotest_domain_rx text := '(@test-fm\.local|@e\.kz)$'; + threshold_ts timestamp with time zone := '{threshold:O}'::timestamp with time zone; +BEGIN + IF array_length(org_ids, 1) IS NOT NULL AND org_ids[1] IS NOT NULL THEN + -- 1. Итеративная очистка tenant-таблиц. + FOR pass IN 1..10 LOOP + remaining := 0; + FOR tbl IN + SELECT c.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = 'public' + AND c.column_name = 'OrganizationId' + AND t.table_type = 'BASE TABLE' + AND c.table_name NOT IN ('organizations', 'users') + LOOP + BEGIN + EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids; + EXCEPTION WHEN foreign_key_violation THEN + remaining := remaining + 1; + END; + END LOOP; + EXIT WHEN remaining = 0; + END LOOP; + + -- 2. AspNet* / OpenIddict для юзеров этих org. + DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN ( + SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN ( + SELECT u.""Id""::text FROM public.users u WHERE u.""OrganizationId"" = ANY(org_ids) + ); + + -- 3. Юзеры этих org. + DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids); + + -- 4. Сами organizations. + DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids); + END IF; + + -- 5. Orphan-юзеры: autotest-паттерн email + не SuperAdmin (OrganizationId + -- IS NOT NULL) + не demo-* + не market@email.com + создан раньше threshold. + -- Эти юзеры могут остаться когда org уже удалена, но AspNetUserRoles — + -- нет (если был cascade NOT), либо если тест не создал org вообще. + DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN ( + SELECT u.""Id"" FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN ( + SELECT u.""Id""::text FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN ( + SELECT u.""Id""::text FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts + ); + DELETE FROM public.users u + WHERE (u.""Email"" ~* autotest_user_rx OR u.""Email"" ~* autotest_domain_rx OR u.""Email"" ~ '[0-9]{{10,}}') + AND u.""Email"" NOT LIKE 'demo-%@food-market.local' + AND u.""Email"" <> 'admin@food-market.local' + AND u.""Email"" <> 'admin@demo-market.local' + AND u.""Email"" <> 'market@email.com' + AND u.""OrganizationId"" IS NOT NULL + AND u.""CreatedAt"" < threshold_ts; +END $do$; +"; + + await _db.Database.ExecuteSqlRawAsync(sql, ct); + _log.LogInformation("Hangfire/PruneAutoTestEntities: удалено {Count} org (старше {Days}д) + orphan autotest-юзеры", + ids.Count, minDays); + return ids.Count; + } }