food-market/src/food-market.api/Background/HousekeepingJobs.cs
nns c69114f8ab feat(s30): PruneAutoTestEntities + одноразовая зачистка стейджа (шаг 7/9)
HousekeepingJobs.PruneAutoTestEntitiesAsync — Hangfire-recurring (02:45 UTC
daily, Cron:PruneAutoTestEntities). Удаляет:
- orgs по regex autotest-паттерна в имени (quality-, soak-, UI-a11y-,
  SmokeOrgA/B-, PerfBaseline-, Reg-, RT-ratetest-, Probe Org, cat3*,
  visual-, factory-smoke-, mt6*, doc4*, auth2*, rep5*, rt8*, wiz9*,
  i18n*, twofa*, iso*, mob1*, axdet-, load-, s19-test- + любое имя
  с [0-9]{10,}) старше 3 дней + все их таблицы с OrganizationId;
- orphan autotest-юзеров (без удалённой org) по regex в email и доменам
  @e.kz / @test-fm.local.

Защищены: SuperAdmin (OrganizationId IS NULL), admin@food-market.local,
admin@demo-market.local, demo-*@food-market.local, market@email.com,
«Demo Market» и «Демо-магазин» — явные исключения.

deploy/stage-prune-autotest.sql — ровно та же логика как SQL-скрипт для
ручного запуска. Прогнал на stage: было 8653 user'ов / 8634 orgs →
стало 300 users / 305 orgs. SuperAdmin/demo/market остались.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 15:49:25 +05:00

482 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Hangfire-джобы для регулярной чистки исторических данных:
/// <c>StockMovement</c> старше 2 лет (компенсирует разрастание таблицы при
/// большом обороте продаж), <c>SuperAdminAuditLog</c> старше 90 дней. Период
/// можно поменять через конфиг <c>Hangfire:Retention:*</c>.
///
/// Реализованы как scoped-сервис, чтобы Hangfire разрешил <see cref="AppDbContext"/>
/// и фильтр tenant'а корректно (контекст НЕТ tenant'а — но cleanup идёт без
/// tenant-фильтра через IgnoreQueryFilters, потому что это межтенантная задача).</summary>
public class HousekeepingJobs
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<HousekeepingJobs> _log;
public HousekeepingJobs(AppDbContext db, IConfiguration cfg, ILogger<HousekeepingJobs> log)
{
_db = db;
_cfg = cfg;
_log = log;
}
/// <summary>Удаляет <see cref="StockMovement"/> старше N дней (по умолчанию 730).
/// Stock-инвариант (Stock.Quantity ≡ Σ StockMovement) ломается на удалённых
/// записях, поэтому удаляем ТОЛЬКО действительно старые движения — там
/// невозможно сослаться через unpost (документы уже закрыты).
///
/// Возвращает количество удалённых строк (для логов/мониторинга).</summary>
public async Task<int> PruneStockMovementsAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Hangfire:Retention:StockMovementDays", 730);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.StockMovements
.IgnoreQueryFilters()
.Where(m => m.OccurredAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneStockMovements: удалено {Count} движений старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Удаляет <c>super_admin_audit_log</c> старше N дней (по умолчанию 90).
/// SuperAdmin'ские действия в каждой орге — отдельный аудит, для compliance
/// храним недолго (квартал) и зачищаем.</summary>
public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Hangfire:Retention:AuditLogDays", 90);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.SuperAdminAuditLogs
.IgnoreQueryFilters()
.Where(a => a.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneAuditLog: удалено {Count} записей старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Sprint 20: удаляет <c>OrgAuditLog</c> старше N дней
/// (по умолчанию 90). Per-tenant журнал мутаций отчётности — растёт
/// быстрее SuperAdmin-аудита, поэтому отдельный prune.</summary>
public async Task<int> PruneOrgAuditLogAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:OrgAuditLogDays", 90);
var threshold = DateTime.UtcNow.AddDays(-days);
var deleted = await _db.OrgAuditLogs
.IgnoreQueryFilters()
.Where(a => a.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneOrgAuditLog: удалено {Count} записей старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Sprint 20: удаляет draft-документы старше N дней (по умолчанию 30).
/// Что чистим: Supply / RetailSale / Demand в статусе Draft, без PostedAt.
/// Каскадно зацепляются связанные lines (FK ON DELETE CASCADE — настроено
/// в configurations).
///
/// Безопасность: draft = пользователь его не провёл, движений не было,
/// удаление не нарушает Stock-инвариант. Per-tenant фильтр НЕ применяется
/// (cross-tenant cleanup), но в WHERE добавляется проверка status.</summary>
public async Task<(int Supplies, int Sales, int Demands)> PruneDraftsAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:DraftDays", 30);
var threshold = DateTime.UtcNow.AddDays(-days);
var supplies = await _db.Supplies.IgnoreQueryFilters()
.Where(s => s.Status == SupplyStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var sales = await _db.RetailSales.IgnoreQueryFilters()
.Where(s => s.Status == RetailSaleStatus.Draft && s.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
var demands = await _db.Demands.IgnoreQueryFilters()
.Where(d => d.Status == DemandStatus.Draft && d.CreatedAt < threshold)
.ExecuteDeleteAsync(ct);
_log.LogInformation("Hangfire/PruneDrafts: supplies={S} sales={Sa} demands={D} (старше {Threshold:O})",
supplies, sales, demands, threshold);
return (supplies, sales, demands);
}
/// <summary>Sprint 20: удаляет refresh-токены OpenIddict со статусом
/// revoked старше N дней (по умолчанию 7). Активные / неревокированные
/// не трогаем — у них собственный TTL.
///
/// OpenIddict таблица — <c>OpenIddictTokens</c>. Status = "revoked" |
/// "redeemed" | "valid". Type = "refresh_token" | "access_token".
/// Удаляем только refresh-токены: access-токены протухают быстро (15
/// минут), их Hangfire-cleanup'a в OpenIddict уже есть.</summary>
public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = default)
{
var days = _cfg.GetValue("Cleanup:RevokedRefreshTokenDays", 7);
var threshold = DateTime.UtcNow.AddDays(-days);
// OpenIddict ED таблица: тип `OpenIddictEntityFrameworkCoreToken`.
// Используем raw SQL чтобы не тащить лишних using-ов; колонки
// прибиты конвенцией OpenIddict.
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync($@"
DELETE FROM ""OpenIddictTokens""
WHERE ""Type"" = 'refresh_token'
AND ""Status"" IN ('revoked', 'redeemed')
AND ""CreationDate"" < {threshold}", ct);
_log.LogInformation("Hangfire/PruneRevokedRefreshTokens: удалено {Count} токенов старше {Threshold:O}",
deleted, threshold);
return deleted;
}
/// <summary>Sprint 25: hard-delete тестовых организаций, которые
/// создаёт <c>~/quality-watchdog.sh</c> по конвенции
/// <c>quality-{epoch}-A/B</c>. Stage-БД иначе разрастается на
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
/// никогда не задеть реальные орги (даже архивные).
///
/// Реализация: динамически находим все таблицы с колонкой
/// <c>OrganizationId</c> через information_schema, и DELETE'им из
/// них строки для match'ащих org-id. Заказ в одной транзакции через
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
/// если делать DELETE в правильном порядке (сначала дочерние,
/// потом родительские); используем общий приём «удаляем по списку
/// org-id из всех таблиц, последний — Organizations».
///
/// Порог давности — <c>Cleanup:QualityTestOrgHours</c> (default 24)
/// чтобы свежие диагностические прогоны не зачищались до того, как
/// инженер посмотрит лог. Возвращает количество удалённых
/// организаций.</summary>
public async Task<int> PruneQualityTestOrgsAsync(CancellationToken ct = default)
{
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
var threshold = DateTime.UtcNow.AddHours(-minHours);
var ids = await _db.Organizations
.IgnoreQueryFilters()
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
.Select(o => o.Id)
.ToListAsync(ct);
if (ids.Count == 0)
{
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
return 0;
}
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
//
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
// (depth FK графа гораздо меньше).
var sql = $@"
DO $do$
DECLARE
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
tbl text;
pass int := 0;
remaining int;
BEGIN
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
-- Пропускаем organizations и users — отдельные шаги ниже.
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;
IF remaining > 0 THEN
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
END IF;
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
SELECT u.""Id"" FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
SELECT u.""Id""::text FROM public.users u
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
);
-- 3. AppUser (public.users) — привязан через OrganizationId, чистим по нему.
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
-- 4. Сама organizations.
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
END $do$;
";
await _db.Database.ExecuteSqlRawAsync(sql, ct);
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
ids.Count, minHours);
return ids.Count;
}
/// <summary>Sprint 30: расширение cleanup'а на ВСЕ автотестовые орги/юзеры
/// (не только <c>quality-*</c>): 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) что
/// <see cref="PruneQualityTestOrgsAsync"/>. Порог давности
/// <c>Cleanup:AutoTestOrgDays</c> (default 3) — чтобы живые e2e-прогоны
/// успевали создать-использовать org и завершиться.
///
/// Защищены от удаления:
/// <list type="bullet">
/// <item>Demo Market и Демо-магазин (seeded) + любые орги с именем
/// ровно <c>Demo Market</c> / <c>Демо-магазин</c>;</item>
/// <item>demo-admin@/demo-cashier@/demo-keeper@ юзеры;</item>
/// <item>SuperAdmin (без OrganizationId) — не удаляется ни при каких
/// условиях (условие OrganizationId IS NOT NULL в юзер-DELETE);</item>
/// <item>market@email.com — явное исключение по email.</item>
/// </list></summary>
public async Task<int> 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<Guid> — удобный способ вытащить только 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<Guid>(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;
}
}