From a8bfd9f45644f8d73fa4aacfd9cd67d717deaa6d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 25 Sep 2026 11:48:02 +0000 Subject: [PATCH] Feature/request type access (#23) Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/23 --- .../RequestTypeAccessController.cs | 44 ++ .../ApplicationServiceCollectionExtensions.cs | 2 + .../RequestTypeAccessDto.cs | 18 + .../RequestTypeAccessService.cs | 107 +++++ .../RequestTypeAccessValidators.cs | 12 + .../Entities/RequestTypeAccess.cs | 14 + .../Persistence/ApprovalRouteDbContext.cs | 10 + ...925092352_AddRequestTypeAccess.Designer.cs | 411 ++++++++++++++++++ .../20260925092352_AddRequestTypeAccess.cs | 48 ++ .../ApprovalRouteDbContextModelSnapshot.cs | 41 ++ .../approvalAdmin/components/app/AdminApp.tsx | 10 + .../approvalAdmin/components/app/types.ts | 10 +- .../RequestTypeAccessScreen.module.scss | 12 + .../screens/RequestTypeAccessScreen.tsx | 236 ++++++++++ .../hooks/useRequestTypeAccess.ts | 10 + .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/requestTypeAccess.ts | 20 + .../src/webparts/approvalAdmin/routing.ts | 18 +- .../webparts/approvalAdmin/services/api.ts | 17 + frontend/src/webparts/approvalAdmin/texts.ts | 28 ++ 20 files changed, 1067 insertions(+), 2 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeAccessController.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessValidators.cs create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeAccess.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.cs create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.module.scss create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts create mode 100644 frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeAccessController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeAccessController.cs new file mode 100644 index 0000000..5e8b8f9 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypeAccessController.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Настройка доступа «видеть все заявки типа», а не только свои. Список +/// email'ов по типу дёргает и портал заявок (проверить текущего пользователя), +/// и Power Automate — поэтому политика AnyAuth, как у RemindersController. +/// +[ApiController] +[Authorize(Policy = AuthPolicies.Any)] +[Route("api/request-type-access")] +[Produces("application/json")] +public sealed class RequestTypeAccessController(RequestTypeAccessService service) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List([FromQuery] int? requestTypeId, CancellationToken ct) => + (await service.ListAsync(requestTypeId, ct)).ToActionResult(); + + [HttpGet("{requestTypeId:int}/emails")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task ListEmails(int requestTypeId, CancellationToken ct) => + (await service.ListEmailsAsync(requestTypeId, ct)).ToActionResult(); + + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] + public async Task Create(CreateRequestTypeAccessRequest body, CancellationToken ct) => + (await service.CreateAsync(body, ct)).ToActionResult(); + + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task Delete(int id, CancellationToken ct) => + (await service.DeleteAsync(id, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 5d50eff..4ae58c7 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reports; using Tnsu.ApprovalRoute.Application.RequestManage; +using Tnsu.ApprovalRoute.Application.RequestTypeAccesses; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -18,6 +19,7 @@ public static class ApplicationServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessDto.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessDto.cs new file mode 100644 index 0000000..30c432c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessDto.cs @@ -0,0 +1,18 @@ +using Tnsu.ApprovalRoute.Application.ListPartitioning; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +/// +/// Вся серия SharePoint-списков данных (архив + текущая партиция) типа заявки — +/// именно на них нужно выдать email'у право чтения, иначе поиск по порталу +/// продолжит отдавать только его собственные заявки: доступ здесь настраивает +/// видимость в UI approval-route, но не права SharePoint, которые остаются +/// отдельным ручным шагом. +/// +public sealed record RequestTypeAccessDto( + int Id, + int RequestTypeId, + string Email, + IReadOnlyList SharePointLists); + +public sealed record CreateRequestTypeAccessRequest(int RequestTypeId, string Email); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessService.cs new file mode 100644 index 0000000..029ce54 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessService.cs @@ -0,0 +1,107 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Application.ListPartitioning; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +public sealed class RequestTypeAccessService( + ApprovalRouteDbContext db, + ListPartitionService partitions, + ICurrentUser currentUser, + ILogger logger) +{ + public async Task>> ListAsync( + int? requestTypeId, CancellationToken cancellationToken) + { + var entities = await db.RequestTypeAccesses.AsNoTracking() + .Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId) + .OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email) + .ToListAsync(cancellationToken); + + // Партиции запрашиваются РАЗ на тип заявки, а не на строку — иначе список + // из N email по одному типу заново обходил бы SharePoint N раз подряд. + var listsByType = new Dictionary>(); + var items = new List(entities.Count); + foreach (var e in entities) + { + var lists = await GetListsCachedAsync(listsByType, e.RequestTypeId, cancellationToken); + items.Add(new RequestTypeAccessDto(e.Id, e.RequestTypeId, e.Email, lists)); + } + + return Result.Ok>(items); + } + + private async Task> GetListsCachedAsync( + Dictionary> cache, + int requestTypeId, + CancellationToken cancellationToken) + { + if (cache.TryGetValue(requestTypeId, out var cached)) + return cached; + + var result = await partitions.ListAsync(requestTypeId, cancellationToken); + var lists = result.IsSuccess ? result.Value : Array.Empty(); + cache[requestTypeId] = lists; + return lists; + } + + /// Список email'ов с доступом к типу заявок — дёргает Power Automate. + public async Task>> ListEmailsAsync(int requestTypeId, CancellationToken cancellationToken) + { + if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == requestTypeId, cancellationToken)) + return Result.Fail>(new NotFoundError("Тип заявки", requestTypeId)); + + var emails = await db.RequestTypeAccesses.AsNoTracking() + .Where(x => x.RequestTypeId == requestTypeId) + .OrderBy(x => x.Email) + .Select(x => x.Email) + .ToListAsync(cancellationToken); + + return Result.Ok>(emails); + } + + public async Task> CreateAsync( + CreateRequestTypeAccessRequest request, CancellationToken cancellationToken) + { + if (!await db.RequestTypes.AsNoTracking().AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken)) + return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); + + var email = request.Email.Trim(); + var exists = await db.RequestTypeAccesses.AsNoTracking() + .AnyAsync(x => x.RequestTypeId == request.RequestTypeId && x.Email == email, cancellationToken); + if (exists) + return Result.Fail( + new ConflictError($"У {email} уже есть доступ к этому типу заявок.")); + + var entity = new RequestTypeAccess { RequestTypeId = request.RequestTypeId, Email = email }; + db.RequestTypeAccesses.Add(entity); + await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Доступ к типу заявки {RequestTypeId} выдан {Email} пользователем {Author}", + entity.RequestTypeId, entity.Email, currentUser.Email); + + var listsResult = await partitions.ListAsync(entity.RequestTypeId, cancellationToken); + var lists = listsResult.IsSuccess ? listsResult.Value : Array.Empty(); + return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email, lists)); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + var entity = await db.RequestTypeAccesses.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Доступ к типу заявки", id)); + + db.RequestTypeAccesses.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + logger.LogInformation( + "Доступ к типу заявки {RequestTypeId} для {Email} отозван пользователем {Author}", + entity.RequestTypeId, entity.Email, currentUser.Email); + + return Result.Ok(); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessValidators.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessValidators.cs new file mode 100644 index 0000000..44fb104 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessValidators.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +public sealed class CreateRequestTypeAccessRequestValidator : AbstractValidator +{ + public CreateRequestTypeAccessRequestValidator() + { + RuleFor(x => x.RequestTypeId).GreaterThan(0).WithMessage("Тип заявки обязателен."); + RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("Укажите корректный email."); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeAccess.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeAccess.cs new file mode 100644 index 0000000..8aa457a --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestTypeAccess.cs @@ -0,0 +1,14 @@ +namespace Tnsu.ApprovalRoute.Domain.Entities; + +/// +/// Право одного пользователя видеть ВСЕ заявки одного типа в портале заявок +/// (а не только свои). У одного человека может быть по строке на каждый тип +/// заявок, к которому ему открыт полный доступ. +/// +public class RequestTypeAccess +{ + public int Id { get; set; } + public int RequestTypeId { get; set; } + public RequestType? RequestType { get; set; } + public string Email { get; set; } = string.Empty; +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 733319c..cec17fd 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -7,6 +7,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); + public DbSet RequestTypeAccesses => Set(); public DbSet RequestListPartitions => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); @@ -35,6 +36,15 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Email).HasMaxLength(256).IsRequired(); + e.HasOne(x => x.RequestType).WithMany().HasForeignKey(x => x.RequestTypeId); + // Одна и та же пара тип+email не должна выдаваться дважды. + e.HasIndex(x => new { x.RequestTypeId, x.Email }).IsUnique(); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.Designer.cs new file mode 100644 index 0000000..0dc3b82 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.Designer.cs @@ -0,0 +1,411 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260925092352_AddRequestTypeAccess")] + partial class AddRequestTypeAccess + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.HasKey("Id") + .HasName("pk_request_type_accesses"); + + b.HasIndex("RequestTypeId", "Email") + .IsUnique() + .HasDatabaseName("ix_request_type_accesses_request_type_id_email"); + + b.ToTable("request_type_accesses", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("AutoApprove") + .HasColumnType("boolean") + .HasColumnName("auto_approve"); + + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_step_conditions"); + + b.HasIndex("StepId", "OrderNo") + .HasDatabaseName("ix_template_route_step_conditions_step_id_order_no"); + + b.ToTable("template_route_step_conditions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => + { + b.Property("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("OptionName") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("ListPartitions") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany() + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_type_accesses_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Routes") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step") + .WithMany("Conditions") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("ListPartitions"); + + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Navigation("Conditions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.cs new file mode 100644 index 0000000..a378d54 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260925092352_AddRequestTypeAccess.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRequestTypeAccess : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "request_type_accesses", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + request_type_id = table.Column(type: "integer", nullable: false), + email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_request_type_accesses", x => x.id); + table.ForeignKey( + name: "fk_request_type_accesses_request_types_request_type_id", + column: x => x.request_type_id, + principalTable: "request_types", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_request_type_accesses_request_type_id_email", + table: "request_type_accesses", + columns: new[] { "request_type_id", "email" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "request_type_accesses"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index da9803b..34002d7 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -119,6 +119,35 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.HasKey("Id") + .HasName("pk_request_type_accesses"); + + b.HasIndex("RequestTypeId", "Email") + .IsUnique() + .HasDatabaseName("ix_request_type_accesses_request_type_id_email"); + + b.ToTable("request_type_accesses", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Property("Id") @@ -309,6 +338,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("RequestType"); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestTypeAccess", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany() + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_type_accesses_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 0db5ee1..efaa5b9 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -14,6 +14,7 @@ import { texts } from '../../texts'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; +import { RequestTypeAccessScreen } from '../screens/RequestTypeAccessScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; @@ -27,6 +28,7 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen'; const NAV_ITEMS: ISidebarItem[] = [ { id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' }, { id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' }, + { id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }, { id: 'routes', title: texts.nav.routes, icon: 'send' }, { id: 'resolve', title: texts.nav.resolve, icon: 'search' }, { id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }, @@ -147,6 +149,14 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint
{route.tab === 'dashboard' && } {route.tab === 'requestTypes' && } + {route.tab === 'requestTypeAccess' && ( + + )} {route.tab === 'routes' && !route.routeId && ( = ({ + api, + dictionary, + initialFilterRequestTypeId, + initialFilterEmail +}) => { + const requestTypes = useRequestTypes(api); + const access = useRequestTypeAccess(api); + const employees = usePortalEmployees(dictionary); + + const [showForm, setShowForm] = React.useState(false); + const [requestTypeId, setRequestTypeId] = React.useState(''); + const [email, setEmail] = React.useState(''); + const submit = useAsyncSubmit(); + + const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ACCESS_FILTER_TYPE_STORAGE_KEY, ''); + const [filterEmail, setFilterEmail] = useLocalStorageState(ACCESS_FILTER_EMAIL_STORAGE_KEY, ''); + + // Ссылка с фильтром сильнее сохранённого значения — только на монтировании + // (см. тот же приём в RoutesScreen). + React.useEffect(() => { + if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { + if (initialFilterEmail) setFilterEmail(initialFilterEmail); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Держим адрес синхронным с фильтрами — отфильтрованную таблицу можно + // передать ссылкой. replaceRoute, а не navigate: смена фильтра не должна + // плодить записи в истории браузера. + React.useEffect(() => { + replaceRoute({ tab: 'requestTypeAccess', accessFilterRequestTypeId: filterRequestTypeId, accessFilterEmail: filterEmail }); + }, [filterRequestTypeId, filterEmail]); + + const deleteTarget = useDeleteTarget(item => api.requestTypeAccess.remove(item.id), access.reload); + + const requestTypeName = (id: number): string => requestTypes.items.find(rt => rt.id === id)?.name ?? `#${id}`; + + const filteredItems = access.items.filter(item => { + if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false; + if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false; + return true; + }); + + // Список SharePoint нужно вручную открыть на чтение этому email — иначе доступ + // здесь настраивает только видимость в UI approval-route, но не в самом + // SharePoint (поиск по порталу отфильтрует чужие заявки правами списка). + const formatSharePointLists = (item: RequestTypeAccess): string => + item.sharePointLists.length === 0 + ? texts.common.empty + : item.sharePointLists.map(l => l.title ?? l.listId).join(', '); + + const openCreate = (): void => { + setRequestTypeId(''); + setEmail(''); + setShowForm(true); + }; + + const submitForm = (): void => { + if (!requestTypeId) { + submit.fail(texts.requestTypeAccess.validationRequestType); + return; + } + if (!email.trim()) { + submit.fail(texts.requestTypeAccess.validationEmail); + return; + } + + submit.run(() => + api.requestTypeAccess.create({ requestTypeId: Number(requestTypeId), email: email.trim() }).then(() => { + setShowForm(false); + access.reload(); + }) + ); + }; + + return ( +
+ + {texts.requestTypeAccess.add}} + /> + +
+
+ + ({ value: String(rt.id), label: rt.name }))} + placeholder={texts.requestTypeAccess.requestTypePlaceholder} + value={requestTypeId} + onChange={e => setRequestTypeId(e.target.value)} + /> + + + + + + + )} + + {deleteTarget.target && ( + + )} +
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts new file mode 100644 index 0000000..294765a --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/hooks/useRequestTypeAccess.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { RequestTypeAccess } from '../models'; +import { ApprovalRouteApi } from '../services/api'; +import { AsyncList, useAsyncList } from './useAsyncList'; + +/** Реестр доступа «видеть все заявки типа» — целиком, по всем типам заявок. */ +export function useRequestTypeAccess(api: ApprovalRouteApi): AsyncList { + const load = React.useCallback(() => api.requestTypeAccess.list(), [api]); + return useAsyncList(load); +} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 2a19b8d..4c8e0dc 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -2,6 +2,7 @@ export * from './enums'; export * from './dictionaries'; export * from './requestTypes'; +export * from './requestTypeAccess'; export * from './requestTypeLists'; export * from './templateRoutes'; export * from './templateRouteSteps'; diff --git a/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts b/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts new file mode 100644 index 0000000..dfacc02 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/requestTypeAccess.ts @@ -0,0 +1,20 @@ +// DTO эндпоинта api/request-type-access (Application/RequestTypeAccesses/*.cs). +import { RequestListPartition } from './requestTypeLists'; + +export interface RequestTypeAccess { + id: number; + requestTypeId: number; + email: string; + /** + * Вся серия SharePoint-списков данных типа заявки (архив + текущая партиция) — + * именно на них нужно вручную выдать email'у право чтения, иначе поиск по + * порталу продолжит отдавать только его собственные заявки: этот реестр + * настраивает видимость в UI approval-route, а не права SharePoint. + */ + sharePointLists: RequestListPartition[]; +} + +export interface CreateRequestTypeAccessInput { + requestTypeId: number; + email: string; +} diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index dd0d996..fe8ba58 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -69,6 +69,14 @@ export interface IAdminRoute { * к тому же отфильтрованному списку, а не сбросить фильтр. */ routesFilter?: string; + /** + * Фильтры таблицы «Доступ к заявкам» (id типа как строка, email) — только при + * tab='requestTypeAccess'. Та же логика, что и у routesFilter: живут в адресе, + * чтобы отфильтрованную ссылку можно было передать и чтобы фильтр не терялся + * при возврате на вкладку. + */ + accessFilterRequestTypeId?: string; + accessFilterEmail?: string; /** Задан только при tab='resolve' и непустом requestTypeId. */ resolve?: IResolveRouteParams; /** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */ @@ -81,10 +89,11 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change'; // 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а // состояние вкладки 'routes' при наличии aaroute (см. parseRoute). -const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests', 'manageByUser']; +const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser']; const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', + 'aaacctype', 'aaaccemail', 'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept', 'aamtype', 'aamreq', 'aamlist', 'aabuser', 'aabtarget' @@ -146,6 +155,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute tab, routeId: routeId && !isNaN(routeId) ? routeId : undefined, routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined, + accessFilterRequestTypeId: (tab === 'requestTypeAccess' ? params.get('aaacctype') : null) || undefined, + accessFilterEmail: (tab === 'requestTypeAccess' ? params.get('aaaccemail') : null) || undefined, resolve, manage, manageByUser: byUserEmail @@ -167,6 +178,11 @@ function buildQuery(route: IAdminRoute): string { if (route.routesFilter) params.set('aarfilter', route.routesFilter); } + if (route.tab === 'requestTypeAccess') { + if (route.accessFilterRequestTypeId) params.set('aaacctype', route.accessFilterRequestTypeId); + if (route.accessFilterEmail) params.set('aaaccemail', route.accessFilterEmail); + } + if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) { params.set('aartype', route.resolve.requestTypeId); if (route.resolve.userEmail) params.set('aauser', route.resolve.userEmail); diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 91e57d5..05b04de 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -1,5 +1,6 @@ import { IHttpClient } from './httpClient'; import { + CreateRequestTypeAccessInput, CreateRequestTypeInput, CreateTemplateRouteInput, CreateTemplateRouteStepInput, @@ -13,6 +14,7 @@ import { PatchTemplateRouteStepInput, RequestListPartition, RequestType, + RequestTypeAccess, RequestTypeListSeries, ResolveTemplateRouteQuery, ResolveTemplateRouteResult, @@ -80,6 +82,21 @@ export class ApprovalRouteApi { this.http.get(`/api/request-types/${requestTypeId}/lists`) }; + // Доступ «видеть все заявки типа» (Application/RequestTypeAccesses/*.cs) — + // тот же список email по типу, что и ручка для Power Automate + // (GET .../{requestTypeId}/emails), только здесь читается ВЕСЬ реестр разом + // (requestTypeId не передан) — админке нужна одна таблица по всем типам. + public readonly requestTypeAccess = { + list: (requestTypeId?: number): Promise => + this.http.get('/api/request-type-access', { requestTypeId }), + + create: (body: CreateRequestTypeAccessInput): Promise => + this.http.post('/api/request-type-access', body), + + remove: (id: number): Promise => + this.http.delete(`/api/request-type-access/${id}`) + }; + public readonly templateRoutes = { list: (requestTypeId?: number): Promise => this.http.get('/api/template-routes', { requestTypeId }), diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 04f8a73..b6048f2 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -93,6 +93,7 @@ export const texts = { title: 'Панель настройки маршрутов', dashboard: 'Дашборд', requestTypes: 'Типы заявок', + requestTypeAccess: 'Доступ к заявкам', routes: 'Маршруты', resolve: 'Проверка маршрута', manageRequests: 'Редактирование заявок', @@ -178,6 +179,33 @@ export const texts = { `Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.` }, + requestTypeAccess: { + breadcrumb: 'Доступ к заявкам', + subtitle: 'Кто видит все заявки типа, а не только свои', + title: 'Доступ к заявкам', + add: 'Выдать доступ', + emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.', + emptyFiltered: 'Ничего не найдено по заданным фильтрам.', + filterRequestTypeLabel: 'Фильтр по типу заявки', + filterRequestTypeAll: 'Все типы заявок', + filterEmailLabel: 'Фильтр по сотруднику', + filterEmailAll: 'Все сотрудники', + filterEmailPlaceholder: 'Найдите по ФИО или email', + columnRequestType: 'Тип заявки', + columnEmail: 'Сотрудник', + columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)', + createTitle: 'Выдать доступ', + requestTypeLabel: 'Тип заявки', + requestTypePlaceholder: 'Выберите тип заявки', + emailLabel: 'Сотрудник', + emailPlaceholder: 'Найдите по ФИО или email', + validationRequestType: 'Выберите тип заявки.', + validationEmail: 'Выберите сотрудника.', + deleteTitle: 'Отозвать доступ?', + deleteText: (email: string, requestTypeName: string): string => + `${email} больше не будет видеть все заявки типа «${requestTypeName}».` + }, + routes: { breadcrumb: 'Маршруты согласования', subtitle: 'Настройка согласований',