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;
+ }
}