From 5bf38cd66f87a3c36c837bd262fe38fa21ac11bc Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 25 Sep 2026 14:24:30 +0500 Subject: [PATCH 1/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=83=20=D0=B8=20?= =?UTF-8?q?API=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0=20=D0=BA=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=83=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Настраиваемый список email по типу заявки: кто может видеть все заявки этого типа, а не только свои. Отдельная ручка со списком email по типу — её будет дёргать Power Automate. Co-Authored-By: Claude Sonnet 5 --- .../RequestTypeAccessController.cs | 44 ++ .../ApplicationServiceCollectionExtensions.cs | 2 + .../RequestTypeAccessDto.cs | 5 + .../RequestTypeAccessService.cs | 78 ++++ .../RequestTypeAccessValidators.cs | 12 + .../Entities/RequestTypeAccess.cs | 14 + .../Persistence/ApprovalRouteDbContext.cs | 10 + ...925092352_AddRequestTypeAccess.Designer.cs | 411 ++++++++++++++++++ .../20260925092352_AddRequestTypeAccess.cs | 48 ++ .../ApprovalRouteDbContextModelSnapshot.cs | 41 ++ 10 files changed, 665 insertions(+) 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 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..3cb7950 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessDto.cs @@ -0,0 +1,5 @@ +namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +public sealed record RequestTypeAccessDto(int Id, int RequestTypeId, string Email); + +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..6de3477 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypeAccesses/RequestTypeAccessService.cs @@ -0,0 +1,78 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypeAccesses; + +public sealed class RequestTypeAccessService( + ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger logger) +{ + public async Task>> ListAsync( + int? requestTypeId, CancellationToken cancellationToken) + { + var items = await db.RequestTypeAccesses.AsNoTracking() + .Where(x => requestTypeId == null || x.RequestTypeId == requestTypeId) + .OrderBy(x => x.RequestTypeId).ThenBy(x => x.Email) + .Select(x => new RequestTypeAccessDto(x.Id, x.RequestTypeId, x.Email)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + /// Список 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); + + return Result.Ok(new RequestTypeAccessDto(entity.Id, entity.RequestTypeId, entity.Email)); + } + + 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") -- 2.34.1 From 58691216ed28b64edda692142ecc60ff810ae15a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 25 Sep 2026 14:33:10 +0500 Subject: [PATCH 2/5] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=BE=D0=BC=20=D0=BA=20=D1=82=D0=B8=D0=BF?= =?UTF-8?q?=D0=B0=D0=BC=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=20=D0=B2=20?= =?UTF-8?q?approvalAdmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка «Доступ к заявкам»: выдать/отозвать доступ сотруднику видеть все заявки выбранного типа — по образцу экрана «Типы заявок». Co-Authored-By: Claude Sonnet 5 --- .../approvalAdmin/components/app/AdminApp.tsx | 3 + .../approvalAdmin/components/app/types.ts | 10 +- .../screens/RequestTypeAccessScreen.tsx | 151 ++++++++++++++++++ .../hooks/useRequestTypeAccess.ts | 10 ++ .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/requestTypeAccess.ts | 12 ++ .../src/webparts/approvalAdmin/routing.ts | 2 +- .../webparts/approvalAdmin/services/api.ts | 17 ++ frontend/src/webparts/approvalAdmin/texts.ts | 21 +++ 9 files changed, 225 insertions(+), 2 deletions(-) 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/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 0db5ee1..1fb17ab 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,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint
{route.tab === 'dashboard' && } {route.tab === 'requestTypes' && } + {route.tab === 'requestTypeAccess' && } {route.tab === 'routes' && !route.routeId && ( = ({ api, dictionary }) => { + 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 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 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}} + /> + + {access.error && {access.error}} + {deleteTarget.error && {deleteTarget.error}} + + {requestTypeName(r.requestTypeId)} }, + { key: 'email', header: texts.requestTypeAccess.columnEmail, render: r => r.email }, + { + key: 'actions', + header: '', + width: '100px', + align: 'right', + sticky: true, + render: r => ( + + deleteTarget.ask(r)} /> + + ) + } + ]} + rows={access.items} + keyOf={r => r.id} + emptyMessage={access.isLoading ? texts.common.loading : texts.requestTypeAccess.emptyList} + /> + + {showForm && ( + setShowForm(false)} + > + + + ({ value: String(rt.id), label: rt.name }))} + placeholder={texts.requestTypeAccess.filterRequestTypeAll} + value={filterRequestTypeId} + onChange={e => setFilterRequestTypeId(e.target.value)} + /> + +
+
+ + setFilterEmail(e.target.value)} + placeholder={texts.requestTypeAccess.filterEmailPlaceholder} + /> + +
+
+ {access.error && {access.error}} {deleteTarget.error && {deleteTarget.error}} @@ -108,9 +173,15 @@ export const RequestTypeAccessScreen: React.FC = ) } ]} - rows={access.items} + rows={filteredItems} keyOf={r => r.id} - emptyMessage={access.isLoading ? texts.common.loading : texts.requestTypeAccess.emptyList} + emptyMessage={ + access.isLoading + ? texts.common.loading + : access.items.length > 0 && filteredItems.length === 0 + ? texts.requestTypeAccess.emptyFiltered + : texts.requestTypeAccess.emptyList + } /> {showForm && ( diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index a607319..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. */ @@ -85,6 +93,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'ro 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/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 7479a73..93020ca 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -185,6 +185,11 @@ export const texts = { title: 'Доступ к заявкам', add: 'Выдать доступ', emptyList: 'Пока никому не выдан доступ ни к одному типу заявок.', + emptyFiltered: 'Ничего не найдено по заданным фильтрам.', + filterRequestTypeLabel: 'Фильтр по типу заявки', + filterRequestTypeAll: 'Все типы заявок', + filterEmailLabel: 'Фильтр по сотруднику', + filterEmailPlaceholder: 'Email сотрудника', columnRequestType: 'Тип заявки', columnEmail: 'Сотрудник', columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)', -- 2.34.1 From 15f24a0d915945be3e9acced518d766c2b071cc1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 25 Sep 2026 16:44:59 +0500 Subject: [PATCH 5/5] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D1=83=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80=D1=82=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?SearchableSelect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Как и везде в админке (форма выдачи доступа, ManageRequestsApproverModal и т.п.) — поиск по ФИО/email вместо свободного текста, точное совпадение вместо подстроки. Co-Authored-By: Claude Sonnet 5 --- .../screens/RequestTypeAccessScreen.tsx | 15 ++++++++++----- frontend/src/webparts/approvalAdmin/texts.ts | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx index d3e5f8d..67ef86d 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RequestTypeAccessScreen.tsx @@ -26,8 +26,7 @@ import { PageHeader, PrimaryCell, SearchableSelect, - Select, - TextInput + Select } from '../ui'; export interface IRequestTypeAccessScreenProps { @@ -84,7 +83,7 @@ export const RequestTypeAccessScreen: React.FC = const filteredItems = access.items.filter(item => { if (filterRequestTypeId && String(item.requestTypeId) !== filterRequestTypeId) return false; - if (filterEmail && item.email.toLowerCase().indexOf(filterEmail.trim().toLowerCase()) === -1) return false; + if (filterEmail && item.email.toLowerCase() !== filterEmail.toLowerCase()) return false; return true; }); @@ -143,10 +142,16 @@ export const RequestTypeAccessScreen: React.FC =
- setFilterEmail(e.target.value)} + loading={employees.isLoading} + allowClear + clearLabel={texts.requestTypeAccess.filterEmailAll} placeholder={texts.requestTypeAccess.filterEmailPlaceholder} + searchPlaceholder={texts.select.employeeSearchPlaceholder} + emptyMessage={texts.select.employeesEmpty} + onChange={setFilterEmail} />
diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 93020ca..b6048f2 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -189,7 +189,8 @@ export const texts = { filterRequestTypeLabel: 'Фильтр по типу заявки', filterRequestTypeAll: 'Все типы заявок', filterEmailLabel: 'Фильтр по сотруднику', - filterEmailPlaceholder: 'Email сотрудника', + filterEmailAll: 'Все сотрудники', + filterEmailPlaceholder: 'Найдите по ФИО или email', columnRequestType: 'Тип заявки', columnEmail: 'Сотрудник', columnSharePointLists: 'Списки SharePoint (выдать доступ вручную)', -- 2.34.1