feat(s29): SuperAdmin advanced-mode toggle + /api/me.advancedMode (шаг 2/8)

- PUT /api/super-admin/organizations/{id}/advanced-mode { enabled: bool }
  — только SuperAdmin, админ орги → 403 от [Authorize(Roles=...)].
- Idempotent: совпадающее состояние → 200 без записи в аудит; разное →
  запись SuperAdminAuditLog (EnableAdvancedMode / DisableAdvancedMode)
  с before/after + IP. Архивную org переключать нельзя (409).
- /api/me теперь отдаёт advancedMode (bool) — читается из Organization
  с IgnoreQueryFilters (SuperAdmin без org → false — работает через
  /api/super-admin/...).
- OrgRow / OrgDetail в SuperAdmin list / detail дополнены IsAdvancedMode
  для бейджа «Простой/Расширенный» (шаг 6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 11:32:12 +05:00
parent 4342318c2c
commit 202763e0d0
3 changed files with 64 additions and 7 deletions

View file

@ -10,7 +10,7 @@ SuperAdmin и только для конкретной организации.
## Чек-лист ## Чек-лист
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false) - [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
- [ ] 2. API `PUT /api/superadmin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403 - [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
- [ ] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях - [ ] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно» - [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются - [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
@ -53,3 +53,13 @@ SuperAdmin и только для конкретной организации.
- Snapshot обновлён (`AppDbContextModelSnapshot.cs`). - Snapshot обновлён (`AppDbContextModelSnapshot.cs`).
- Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`. - Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`.
- `dotnet build food-market.api` — ✓ (0 error, warnings — старые, не наши). - `dotnet build food-market.api` — ✓ (0 error, warnings — старые, не наши).
### 2026-10-04 — Шаг 2 ✓
- `PUT /api/super-admin/organizations/{id}/advanced-mode { "enabled": bool }` в `SuperAdminOrganizationsController` — `[Authorize(Roles="SuperAdmin")]`. Админ орги получает 403 от стандартного Authorize-фильтра (роль Admin ≠ SuperAdmin); в контроллере сверху уже `[Authorize(Roles="SuperAdmin")]` — не нужно отдельного `Forbid()`.
- Запись в `SuperAdminAuditLog`: ActionType `EnableAdvancedMode` / `DisableAdvancedMode`, Description, Changes `{"before":…,"after":…}`, IpAddress. Идемпотентный переключатель: если состояние совпадает — 200 без аудит-записи.
- Архивную орг переключать нельзя — 409.
- `/api/me` отдаёт `advancedMode` (bool). SuperAdmin без org → false (это ОК — он работает через `/api/super-admin/...`).
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
- `dotnet build food-market.api` — ✓ (0 error).

View file

@ -31,7 +31,8 @@ public record OrgRow(
Guid Id, string Name, string CountryCode, Guid Id, string Name, string CountryCode,
bool IsActive, bool IsArchived, DateTime? ArchivedAt, bool IsActive, bool IsArchived, DateTime? ArchivedAt,
DateTime CreatedAt, int EmployeeCount, int ProductCount, DateTime CreatedAt, int EmployeeCount, int ProductCount,
DateTime? LastLoginAt); DateTime? LastLoginAt,
bool IsAdvancedMode);
public record OrgDetail( public record OrgDetail(
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -39,7 +40,8 @@ public record OrgDetail(
bool IsActive, bool IsArchived, DateTime? ArchivedAt, bool IsActive, bool IsArchived, DateTime? ArchivedAt,
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail, Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
DateTime CreatedAt, DateTime? UpdatedAt, DateTime CreatedAt, DateTime? UpdatedAt,
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth); int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
bool IsAdvancedMode);
public record OrgInput( public record OrgInput(
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
@ -53,6 +55,7 @@ public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string
public record ArchiveRequest(string ConfirmationName); public record ArchiveRequest(string ConfirmationName);
public record DeleteRequest(string ConfirmationName); public record DeleteRequest(string ConfirmationName);
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason); public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
public record AdvancedModeRequest(bool Enabled);
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<OrgRow>>> List( public async Task<ActionResult<PagedResult<OrgRow>>> List(
@ -77,7 +80,8 @@ public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id), _db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id), _db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null) _db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
.Max(u => (DateTime?)u.LastLoginAt))) .Max(u => (DateTime?)u.LastLoginAt),
o.IsAdvancedMode))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -229,6 +233,36 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
return NoContent(); return NoContent();
} }
/// <summary>Sprint 29 — переключатель расширенного режима организации.
/// По умолчанию <c>false</c> → UI/API видят только «простой» набор функций.
/// Переключать может только SuperAdmin; админ организации — 403 (гейтим
/// ролью на уровне контроллера). Idempotent: если состояние совпадает —
/// вернём 200 + <c>IsAdvancedMode</c>, без записи в аудит.</summary>
[HttpPut("{id:guid}/advanced-mode")]
public async Task<IActionResult> SetAdvancedMode(Guid id, [FromBody] AdvancedModeRequest req, CancellationToken ct)
{
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
if (o is null) return NotFound();
if (o.IsArchived)
return Conflict(new { error = "Организация в архиве — сначала восстанови её." });
if (o.IsAdvancedMode == req.Enabled)
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = false });
var before = o.IsAdvancedMode;
o.IsAdvancedMode = req.Enabled;
await _db.SaveChangesAsync(ct);
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
var description = req.Enabled
? $"Включен расширенный режим «{o.Name}»"
: $"Выключен расширенный режим «{o.Name}»";
await LogAsync(actionType, o.Id, description, null,
$"{{\"before\":{before.ToString().ToLower()},\"after\":{req.Enabled.ToString().ToLower()}}}", ct);
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = true });
}
[HttpPost("{id:guid}/change-owner")] [HttpPost("{id:guid}/change-owner")]
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct) public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
{ {
@ -267,7 +301,8 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
o.DefaultCurrencyId, o.DefaultCurrency?.Code, o.DefaultCurrencyId, o.DefaultCurrency?.Code,
o.IsActive, o.IsArchived, o.ArchivedAt, o.IsActive, o.IsArchived, o.ArchivedAt,
o.AccountOwnerUserId, owner?.FullName, owner?.Email, o.AccountOwnerUserId, owner?.FullName, owner?.Email,
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies); o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
o.IsAdvancedMode);
} }
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct) private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)

View file

@ -632,10 +632,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// получает /no-organization fallback вместо белого экрана на /dashboard. // получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = false; bool hasLiveOrg = false;
bool hasActiveEmployee = false; bool hasActiveEmployee = false;
// Sprint 29 (simple-mode): флаг расширенного режима организации.
// false по умолчанию → UI показывает только «простое» меню.
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
bool advancedMode = false;
if (Guid.TryParse(orgIdRaw, out var orgId)) if (Guid.TryParse(orgIdRaw, out var orgId))
{ {
hasLiveOrg = await db.Organizations.IgnoreQueryFilters() var org = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived); .Where(o => o.Id == orgId)
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
.FirstOrDefaultAsync();
if (org is not null)
{
hasLiveOrg = !org.IsArchived;
advancedMode = org.IsAdvancedMode;
}
if (Guid.TryParse(subRaw, out var sub)) if (Guid.TryParse(subRaw, out var sub))
{ {
hasActiveEmployee = await db.Employees.IgnoreQueryFilters() hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
@ -652,6 +663,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
orgId = orgIdRaw, orgId = orgIdRaw,
hasLiveOrg, hasLiveOrg,
hasActiveEmployee, hasActiveEmployee,
advancedMode,
}); });
}).RequireAuthorization(); }).RequireAuthorization();