feat(super-admin): настраиваемый retention period для архивных орг

Раньше «удалить орг навсегда» было захардкожено на 30 дней архива.
Теперь — глобальная системная настройка SuperAdmin'а.

Domain/DB:
- SystemSettings : Entity (single-row table system_settings).
  Поле ArchiveRetentionDays (int, default 30). Структура расширяется
  именованными полями по мере необходимости — без key-value generic'а.
- Migration Phase4e_SystemSettings создаёт таблицу с default 30.
- DevDataSeeder: при первом старте создаёт single-row дефолт.

API:
- GET /api/super-admin/settings — текущие настройки.
- PUT /api/super-admin/settings — обновить с валидацией [0..3650].
  Audit-log запись ActionType=EditSystemSettings с before/after.
- SuperAdminOrganizationsController.Delete: хардкод 30 заменён
  чтением SystemSettings.ArchiveRetentionDays. При retention=0 —
  удаление доступно сразу после архивации.

UI:
- /super-admin/settings — страница «Системные настройки».
  Select из 6 опций (0/1/3/7/14/30), warning-баннер при выборе
  «Немедленно». Кнопка «Сохранить» disabled пока нет изменений.
- В SuperAdminLayout убрана пометка «скоро» с пункта «Системные
  настройки» — раздел активен.
- SuperAdminOrganizationsPage: кнопка «Удалить навсегда» теперь
  читает retentionDays из API; tooltip показывает оставшиеся дни
  «Доступно через X дн. (retention N)»; при retention=0 — всегда
  active для архивных орг.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 17:59:24 +05:00
parent 2cadb6e5d9
commit fc3f63c49a
12 changed files with 2293 additions and 10 deletions

View file

@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace foodmarket.Api.Controllers.SuperAdmin; namespace foodmarket.Api.Controllers.SuperAdmin;
@ -47,6 +48,49 @@ public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct)); RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct));
} }
public record SystemSettingsDto(int ArchiveRetentionDays);
public record SystemSettingsInput(int ArchiveRetentionDays);
[HttpGet("settings")]
public async Task<ActionResult<SystemSettingsDto>> GetSettings(CancellationToken ct)
{
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
return new SystemSettingsDto(s?.ArchiveRetentionDays ?? 30);
}
[HttpPut("settings")]
public async Task<ActionResult<SystemSettingsDto>> UpdateSettings([FromBody] SystemSettingsInput input, CancellationToken ct)
{
if (input.ArchiveRetentionDays < 0 || input.ArchiveRetentionDays > 3650)
return BadRequest(new { error = "ArchiveRetentionDays должен быть в диапазоне 03650." });
var s = await _db.SystemSettings.FirstOrDefaultAsync(ct);
var prev = s?.ArchiveRetentionDays;
if (s is null)
{
s = new SystemSettings { ArchiveRetentionDays = input.ArchiveRetentionDays };
_db.SystemSettings.Add(s);
}
else
{
s.ArchiveRetentionDays = input.ArchiveRetentionDays;
}
await _db.SaveChangesAsync(ct);
// Audit-log смены настройки
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = "EditSystemSettings",
Description = $"ArchiveRetentionDays: {prev?.ToString() ?? "(default 30)"} → {input.ArchiveRetentionDays}",
ChangesJson = $"{{\"archiveRetentionDays\":{{\"from\":{prev?.ToString() ?? "30"},\"to\":{input.ArchiveRetentionDays}}}}}",
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
return new SystemSettingsDto(s.ArchiveRetentionDays);
}
public record AuditRow( public record AuditRow(
Guid Id, DateTime CreatedAt, Guid SuperAdminUserId, Guid Id, DateTime CreatedAt, Guid SuperAdminUserId,
string ActionType, Guid? OrganizationId, string? OrganizationName, string ActionType, Guid? OrganizationId, string? OrganizationName,

View file

@ -189,8 +189,11 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
{ {
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound(); if (o is null) return NotFound();
if (!o.IsArchived || o.ArchivedAt is null || o.ArchivedAt > DateTime.UtcNow.AddDays(-30)) if (!o.IsArchived || o.ArchivedAt is null)
return Conflict(new { error = "Удалить можно только организации в архиве >30 дней." }); return Conflict(new { error = "Удалить можно только архивированную организацию." });
var retentionDays = await _db.SystemSettings.Select(s => (int?)s.ArchiveRetentionDays).FirstOrDefaultAsync(ct) ?? 30;
if (o.ArchivedAt > DateTime.UtcNow.AddDays(-retentionDays))
return Conflict(new { error = $"Доступно через {retentionDays} дней архива." });
if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." });
await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null,
$"{{\"name\":\"{o.Name}\"}}", ct); $"{{\"name\":\"{o.Name}\"}}", ct);

View file

@ -93,6 +93,15 @@ public async Task StartAsync(CancellationToken ct)
} }
await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct); await SeedAdminEmployeeAsync(db, demoOrg.Id, admin?.Id, ct);
// Глобальные SystemSettings — single-row. Сидируем дефолт 30 дней
// retention если ещё нет записи.
var anySettings = await db.SystemSettings.AnyAsync(ct);
if (!anySettings)
{
db.SystemSettings.Add(new SystemSettings { ArchiveRetentionDays = 30 });
await db.SaveChangesAsync(ct);
}
} }
/// <summary>Привязывает существующего admin@food-market.local к /// <summary>Привязывает существующего admin@food-market.local к

View file

@ -0,0 +1,15 @@
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Organizations;
/// <summary>Глобальные настройки платформы (single-row table). Управляются
/// SuperAdmin'ом в /super-admin/settings. Не tenant-scoped — действует на всю
/// систему. Расширяется добавлением новых типизированных полей по мере
/// необходимости (KISS — без key-value generic'а).</summary>
public class SystemSettings : Entity
{
/// <summary>Сколько дней должен пройти после архивации, прежде чем
/// SuperAdmin может удалить организацию навсегда. 0 = удалять сразу
/// после архивации (для dev/staging). По умолчанию 30 (prod-safe).</summary>
public int ArchiveRetentionDays { get; set; } = 30;
}

View file

@ -50,6 +50,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Employee> Employees => Set<Employee>(); public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>(); public DbSet<EmployeeRetailPointAssignment> EmployeeRetailPointAssignments => Set<EmployeeRetailPointAssignment>();
public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>(); public DbSet<SuperAdminAuditLog> SuperAdminAuditLogs => Set<SuperAdminAuditLog>();
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@ -80,6 +81,11 @@ protected override void OnModelCreating(ModelBuilder builder)
b.HasIndex(o => o.IsArchived); b.HasIndex(o => o.IsArchived);
}); });
builder.Entity<SystemSettings>(b =>
{
b.ToTable("system_settings");
});
builder.Entity<SuperAdminAuditLog>(b => builder.Entity<SuperAdminAuditLog>(b =>
{ {
b.ToTable("super_admin_audit_log"); b.ToTable("super_admin_audit_log");

View file

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Глобальные настройки платформы (single-row): ArchiveRetentionDays
/// (по умолчанию 30) — сколько дней архивная орга должна пробыть в архиве
/// прежде чем её можно удалить навсегда. Юзер настраивает через UI
/// /super-admin/settings; раньше был хардкод 30 в SuperAdminOrganizationsController.</summary>
public partial class Phase4e_SystemSettings : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "system_settings",
schema: "public",
columns: table => new
{
Id = table.Column<System.Guid>(type: "uuid", nullable: false),
ArchiveRetentionDays = table.Column<int>(type: "integer", nullable: false, defaultValue: 30),
CreatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<System.DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: table => table.PrimaryKey("PK_system_settings", x => x.Id));
}
protected override void Down(MigrationBuilder b)
{
b.DropTable(name: "system_settings", schema: "public");
}
}
}

View file

@ -1900,6 +1900,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("foodmarket.Domain.Organizations.SystemSettings", b =>
{
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");
b.Property<int>("ArchiveRetentionDays").HasColumnType("integer");
b.Property<DateTime>("CreatedAt").HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt").HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("system_settings", "public");
});
modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b => modelBuilder.Entity("foodmarket.Domain.Organizations.SuperAdminAuditLog", b =>
{ {
b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid"); b.Property<Guid>("Id").ValueGeneratedOnAdd().HasColumnType("uuid");

View file

@ -8,6 +8,7 @@ import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage
import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage' import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage'
import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage' import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage'
import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage' import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
import { SuperAdminSettingsPage } from '@/pages/SuperAdminSettingsPage'
import { CountriesPage } from '@/pages/CountriesPage' import { CountriesPage } from '@/pages/CountriesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
@ -60,6 +61,7 @@ export default function App() {
<Route path="countries" element={<CountriesPage />} /> <Route path="countries" element={<CountriesPage />} />
<Route path="groups" element={<ProductGroupsPage />} /> <Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<UnitsOfMeasurePage />} /> <Route path="units" element={<UnitsOfMeasurePage />} />
<Route path="settings" element={<SuperAdminSettingsPage />} />
</Route> </Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard: {/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:

View file

@ -32,7 +32,7 @@ const NAV: NavSection[] = [
{ group: 'Тех. обслуживание', items: [ { group: 'Тех. обслуживание', items: [
{ to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true }, { to: '/super-admin/health', icon: HeartPulse, label: 'Здоровье', soon: true },
{ to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true }, { to: '/super-admin/backups', icon: HardDriveDownload, label: 'Бэкапы', soon: true },
{ to: '/super-admin/settings', icon: Settings, label: 'Системные настройки', soon: true }, { to: '/super-admin/settings', icon: Settings, label: 'Системные настройки' },
]}, ]},
] ]

View file

@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react' import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react'
import { setOrgOverride } from '@/lib/api' import { api, setOrgOverride } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
@ -10,7 +11,6 @@ import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field' import { Field, TextInput } from '@/components/Field'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { api } from '@/lib/api'
const URL = '/api/super-admin/organizations' const URL = '/api/super-admin/organizations'
@ -45,9 +45,20 @@ export function SuperAdminOrganizationsPage() {
list.refetch() list.refetch()
} }
const isOver30Days = (iso: string | null) => { // Период до возможности удалить навсегда — читаем из системных настроек.
const settings = useQuery({
queryKey: ['/api/super-admin/settings'],
queryFn: async () => (await api.get<{ archiveRetentionDays: number }>('/api/super-admin/settings')).data,
})
const retentionDays = settings.data?.archiveRetentionDays ?? 30
const canDelete = (iso: string | null) => {
if (!iso) return false if (!iso) return false
return new Date(iso).getTime() < Date.now() - 30 * 24 * 60 * 60 * 1000 if (retentionDays === 0) return true
return new Date(iso).getTime() < Date.now() - retentionDays * 24 * 60 * 60 * 1000
}
const daysSinceArchive = (iso: string | null) => {
if (!iso) return 0
return Math.floor((Date.now() - new Date(iso).getTime()) / (24 * 60 * 60 * 1000))
} }
return ( return (
@ -116,9 +127,11 @@ export function SuperAdminOrganizationsPage() {
className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded"> className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded">
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-4 h-4" />
</button> </button>
<button title={isOver30Days(r.archivedAt) ? 'Удалить навсегда' : 'Удаление доступно через 30 дней архива'} <button title={canDelete(r.archivedAt)
disabled={!isOver30Days(r.archivedAt)} ? 'Удалить навсегда'
onClick={() => isOver30Days(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))} : `Доступно через ${Math.max(0, retentionDays - daysSinceArchive(r.archivedAt))} дн. (retention ${retentionDays})`}
disabled={!canDelete(r.archivedAt)}
onClick={() => canDelete(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed"> className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
import { Field, Select } from '@/components/Field'
import { Button } from '@/components/Button'
interface SystemSettingsDto { archiveRetentionDays: number }
const RETENTION_OPTIONS = [
{ value: 0, label: 'Немедленно (0 дней)' },
{ value: 1, label: '1 день' },
{ value: 3, label: '3 дня' },
{ value: 7, label: '7 дней' },
{ value: 14, label: '14 дней' },
{ value: 30, label: '30 дней (рекомендуется)' },
]
export function SuperAdminSettingsPage() {
const qc = useQueryClient()
const { data } = useQuery({
queryKey: ['/api/super-admin/settings'],
queryFn: async () => (await api.get<SystemSettingsDto>('/api/super-admin/settings')).data,
})
const [retention, setRetention] = useState<number | null>(null)
const [savedHint, setSavedHint] = useState(false)
useEffect(() => { if (data && retention === null) setRetention(data.archiveRetentionDays) }, [data, retention])
const save = useMutation({
mutationFn: async () => {
if (retention === null) return
await api.put<SystemSettingsDto>('/api/super-admin/settings', { archiveRetentionDays: retention })
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['/api/super-admin/settings'] })
setSavedHint(true)
setTimeout(() => setSavedHint(false), 2500)
},
})
const dirty = retention !== null && data && retention !== data.archiveRetentionDays
const isImmediate = retention === 0
return (
<div className="h-full overflow-auto">
<div className="max-w-2xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader
title="Системные настройки"
description="Глобальные параметры платформы. Применяются ко всем организациям."
/>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold">Архивирование организаций</h3>
<Field label="Период до полного удаления из архива">
<Select value={String(retention ?? 30)} onChange={(e) => setRetention(Number(e.target.value))}>
{RETENTION_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</Select>
</Field>
<p className="text-xs text-slate-500">
Через указанное время архивная организация может быть удалена навсегда.
При значении «Немедленно» кнопка удаления доступна сразу после архивации.
Рекомендуется 730 дней в продакшене.
</p>
{isImmediate && (
<div className="flex items-start gap-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 px-3 py-2">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-200">
Удалённые организации нельзя восстановить. Это поведение подходит для разработки/тестирования,
в продакшене не рекомендуется.
</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<Button onClick={() => save.mutate()} disabled={!dirty || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
{savedHint && (
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Сохранено
</span>
)}
</div>
</section>
</div>
</div>
)
}