diff --git a/src/Escalated/Controllers/Admin/AdminSkillsController.cs b/src/Escalated/Controllers/Admin/AdminSkillsController.cs new file mode 100644 index 0000000..177bd16 --- /dev/null +++ b/src/Escalated/Controllers/Admin/AdminSkillsController.cs @@ -0,0 +1,366 @@ +using System.Text.Json.Serialization; +using Escalated.Data; +using Escalated.Dtos.Admin; +using Escalated.Models; +using Escalated.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Escalated.Controllers.Admin; + +[ApiController] +[Route("support/admin/skills")] +public class AdminSkillsController : ControllerBase +{ + private readonly EscalatedDbContext _db; + private readonly IUserDirectory _directory; + + public AdminSkillsController(EscalatedDbContext db, IUserDirectory directory) + { + _db = db; + _directory = directory; + } + + /// GET /support/admin/skills + [HttpGet] + public async Task Index(CancellationToken ct = default) + { + var skills = await _db.Skills + .AsNoTracking() + .OrderBy(s => s.Name) + .Select(s => new SkillIndexRow( + s.Id, + s.Name, + s.AgentSkills.Count, + s.RoutingTags.Count, + s.RoutingDepartments.Count, + s.UpdatedAt)) + .ToListAsync(ct); + + return Ok(new SkillsIndexEnvelope(skills)); + } + + /// GET /support/admin/skills/create + [HttpGet("create")] + public async Task Create(CancellationToken ct = default) + { + var available = await BuildAvailableLookups(ct); + return Ok(new SkillEditEnvelope( + Skill: EmptySkillDraft(), + available.AvailableAgents, + available.AvailableTags, + available.AvailableDepartments)); + } + + /// POST /support/admin/skills + [HttpPost] + public async Task Store([FromBody] CreateSkillDto dto, CancellationToken ct = default) + { + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + var validationErr = await ValidateRoutingIdsAsync(dto.RoutingTagIds, dto.RoutingDepartmentIds, ct); + if (validationErr is not null) + { + return BadRequest(new { error = validationErr }); + } + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + var now = DateTime.UtcNow; + var slug = await ResolveUniqueSlugAsync(Skill.GenerateSlug(dto.Name.Trim()), null, ct); + + var skill = new Skill + { + Name = dto.Name.Trim(), + Slug = slug, + Description = dto.Description?.Trim(), + CreatedAt = now, + UpdatedAt = now, + }; + _db.Skills.Add(skill); + await _db.SaveChangesAsync(ct); + + await ReplaceAssignmentsAsync(skill.Id, dto.RoutingTagIds, dto.RoutingDepartmentIds, dto.Agents, now, ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return Ok(new CreateSkillEnvelope(skill.Id)); + } + + /// GET /support/admin/skills/{id}/edit + [HttpGet("{id:int}/edit")] + public async Task Edit(int id, CancellationToken ct = default) + { + var skill = await _db.Skills + .AsNoTracking() + .Include(s => s.AgentSkills) + .Include(s => s.RoutingTags) + .Include(s => s.RoutingDepartments) + .FirstOrDefaultAsync(s => s.Id == id, ct); + + if (skill is null) + { + return NotFound(new { error = "Skill not found." }); + } + + var lookups = await BuildAvailableLookups(ct); + var envelope = SkillEditEnvelope.From(skill, lookups); + return Ok(envelope); + } + + /// PUT /support/admin/skills/{id} + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateSkillDto dto, CancellationToken ct = default) + { + if (!ModelState.IsValid) + { + return ValidationProblem(ModelState); + } + + var validationErr = await ValidateRoutingIdsAsync(dto.RoutingTagIds, dto.RoutingDepartmentIds, ct); + if (validationErr is not null) + { + return BadRequest(new { error = validationErr }); + } + + var skill = await _db.Skills.FirstOrDefaultAsync(s => s.Id == id, ct); + if (skill is null) + { + return NotFound(new { error = "Skill not found." }); + } + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + skill.Name = dto.Name.Trim(); + skill.Description = dto.Description?.Trim(); + + skill.Slug = await ResolveUniqueSlugAsync(Skill.GenerateSlug(skill.Name), skill.Id, ct); + + skill.UpdatedAt = DateTime.UtcNow; + + await ReplaceAssignmentsAsync(skill.Id, dto.RoutingTagIds, dto.RoutingDepartmentIds, dto.Agents, skill.UpdatedAt, ct); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return Ok(new { message = "Skill updated." }); + } + + /// DELETE /support/admin/skills/{id} + [HttpDelete("{id:int}")] + public async Task Destroy(int id, CancellationToken ct = default) + { + var skill = await _db.Skills.FirstOrDefaultAsync(s => s.Id == id, ct); + if (skill is null) + { + return NotFound(new { error = "Skill not found." }); + } + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + await ClearSkillAssignmentsAsync(id, ct); + _db.Skills.Remove(skill); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + return Ok(new { message = "Skill deleted." }); + } + + private async Task ClearSkillAssignmentsAsync(int skillId, CancellationToken ct) + { + var tags = await _db.SkillRoutingTags.Where(r => r.SkillId == skillId).ToListAsync(ct); + var deptRows = await _db.SkillRoutingDepartments.Where(r => r.SkillId == skillId).ToListAsync(ct); + var entries = await _db.AgentSkills.Where(a => a.SkillId == skillId).ToListAsync(ct); + + _db.SkillRoutingTags.RemoveRange(tags); + _db.SkillRoutingDepartments.RemoveRange(deptRows); + _db.AgentSkills.RemoveRange(entries); + } + + private async Task<(IReadOnlyList AvailableAgents, IReadOnlyList AvailableTags, IReadOnlyList AvailableDepartments)> BuildAvailableLookups( + CancellationToken ct) + { + var tags = await _db.Tags.AsNoTracking() + .OrderBy(t => t.Name) + .Select(t => new TagPick(t.Id, t.Name)) + .ToListAsync(ct); + + var departments = await _db.Departments.AsNoTracking() + .Where(d => d.IsActive) + .OrderBy(d => d.Name) + .Select(d => new DeptPick(d.Id, d.Name)) + .ToListAsync(ct); + + var agentRoleIds = await _db.RoleUsers + .Where(ru => ru.Role != null + && (ru.Role!.Slug == AdminUsersController.AgentRoleSlug + || ru.Role.Slug == AdminUsersController.AdminRoleSlug)) + .Select(ru => ru.UserId) + .Distinct() + .ToListAsync(ct); + + var picks = new List(); + foreach (var userId in agentRoleIds.OrderBy(i => i)) + { + var entry = await _directory.FindAsync(userId, ct); + if (entry is not null) + { + picks.Add(new AgentPick(entry.Id, entry.Name, entry.Email)); + } + } + + return (picks, tags, departments); + } + + private static SkillDetailDto EmptySkillDraft() => + new(0, string.Empty, null, [], [], []); + + private async Task ReplaceAssignmentsAsync( + int skillId, + int[]? routingTagIds, + int[]? routingDepartmentIds, + AgentSkillEntryDto[]? agents, + DateTime timestamps, + CancellationToken ct) + { + await ClearSkillAssignmentsAsync(skillId, ct); + + var tagDistinct = routingTagIds?.Distinct().ToList() ?? []; + foreach (var tagId in tagDistinct) + { + _db.SkillRoutingTags.Add(new SkillRoutingTag { SkillId = skillId, TagId = tagId }); + } + + var deptDistinct = routingDepartmentIds?.Distinct().ToList() ?? []; + foreach (var departmentId in deptDistinct) + { + _db.SkillRoutingDepartments.Add(new SkillRoutingDepartment { SkillId = skillId, DepartmentId = departmentId }); + } + + if (agents is { Length: > 0 }) + { + foreach (var row in agents + .GroupBy(a => a.UserId) + .Select(g => g.Last())) + { + _db.AgentSkills.Add(new AgentSkill + { + UserId = row.UserId, + SkillId = skillId, + Proficiency = row.Proficiency, + CreatedAt = timestamps, + UpdatedAt = timestamps, + }); + } + } + } + + private async Task ValidateRoutingIdsAsync(int[]? tagIds, int[]? departmentIds, CancellationToken ct) + { + var tagDistinct = tagIds?.Distinct().ToArray() ?? []; + if (tagDistinct.Length != 0) + { + var count = await _db.Tags.Where(t => tagDistinct.Contains(t.Id)).CountAsync(ct); + if (count != tagDistinct.Length) + { + return "One or more routing tag ids were not found."; + } + } + + var deptDistinct = departmentIds?.Distinct().ToArray() ?? []; + if (deptDistinct.Length != 0) + { + var count = await _db.Departments.Where(d => deptDistinct.Contains(d.Id)).CountAsync(ct); + if (count != deptDistinct.Length) + { + return "One or more routing department ids were not found."; + } + } + + return null; + } + + private async Task ResolveUniqueSlugAsync(string baseSlug, int? exceptSkillId, CancellationToken ct) + { + if (!await IsSlugTakenAsync(baseSlug, exceptSkillId, ct)) + { + return baseSlug; + } + + for (var suffix = 2; suffix < int.MaxValue; suffix++) + { + var candidate = $"{baseSlug}-{suffix}"; + if (!await IsSlugTakenAsync(candidate, exceptSkillId, ct)) + { + return candidate; + } + } + + throw new InvalidOperationException("Unable to allocate a unique skill slug."); + } + + private Task IsSlugTakenAsync(string slug, int? exceptSkillId, CancellationToken ct) + { + return exceptSkillId is null + ? _db.Skills.AnyAsync(s => s.Slug == slug, ct) + : _db.Skills.AnyAsync(s => s.Slug == slug && s.Id != exceptSkillId, ct); + } + +#pragma warning disable CA1034 // Nested types acceptable for grouped API contract records + + public sealed record SkillsIndexEnvelope( + [property: JsonPropertyName("skills")] IReadOnlyList Skills); + + public sealed record SkillIndexRow(int Id, string Name, int AgentsCount, int RoutingTagsCount, int RoutingDepartmentsCount, DateTime UpdatedAt); + + public sealed record CreateSkillEnvelope([property: JsonPropertyName("id")] int Id); + + /// Shape returned by Edit and Create for the Vue admin Skills screen. + public sealed record SkillEditEnvelope( + [property: JsonPropertyName("skill")] SkillDetailDto Skill, + [property: JsonPropertyName("availableAgents")] IReadOnlyList AvailableAgents, + [property: JsonPropertyName("availableTags")] IReadOnlyList AvailableTags, + [property: JsonPropertyName("availableDepartments")] IReadOnlyList AvailableDepartments) + { + public static SkillEditEnvelope From(Skill s, ( + IReadOnlyList AvailableAgents, + IReadOnlyList AvailableTags, + IReadOnlyList AvailableDepartments) lookups) => + new( + Skill: new SkillDetailDto( + s.Id, + s.Name, + s.Description, + s.RoutingTags.Select(rt => rt.TagId).Distinct().OrderBy(i => i).ToArray(), + s.RoutingDepartments.Select(rd => rd.DepartmentId).Distinct().OrderBy(i => i).ToArray(), + s.AgentSkills + .GroupBy(a => a.UserId) + .Select(g => g.Last()) + .OrderBy(r => r.UserId) + .Select(r => new AgentSkillAssignmentDto(r.UserId, r.Proficiency)) + .ToArray()), + lookups.AvailableAgents, + lookups.AvailableTags, + lookups.AvailableDepartments); + } + + public sealed record SkillDetailDto( + int Id, + string Name, + string? Description, + int[] RoutingTagIds, + int[] RoutingDepartmentIds, + AgentSkillAssignmentDto[] Agents); + + public sealed record AgentSkillAssignmentDto(int UserId, int Proficiency); + + public sealed record AgentPick(int Id, string? Name, string? Email); + + public sealed record TagPick(int Id, string Name); + + public sealed record DeptPick(int Id, string Name); + +#pragma warning restore CA1034 +} diff --git a/src/Escalated/Data/EscalatedDbContext.cs b/src/Escalated/Data/EscalatedDbContext.cs index ef98272..473d89e 100644 --- a/src/Escalated/Data/EscalatedDbContext.cs +++ b/src/Escalated/Data/EscalatedDbContext.cs @@ -25,6 +25,8 @@ public EscalatedDbContext(DbContextOptions options) : base(o public DbSet AgentCapacities => Set(); public DbSet Skills => Set(); public DbSet AgentSkills => Set(); + public DbSet SkillRoutingTags => Set(); + public DbSet SkillRoutingDepartments => Set(); public DbSet CannedResponses => Set(); public DbSet Macros => Set(); public DbSet SideConversations => Set(); @@ -59,342 +61,6 @@ public EscalatedDbContext(DbContextOptions options) : base(o protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - - // Table names with prefix - const string prefix = "escalated_"; - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}tickets"); - e.HasIndex(t => t.Reference).IsUnique(); - e.HasIndex(t => t.Status); - e.HasIndex(t => t.Priority); - e.HasIndex(t => t.AssignedTo); - e.HasIndex(t => t.DepartmentId); - e.HasIndex(t => t.GuestToken); - e.HasIndex(t => t.GuestEmail); - e.HasIndex(t => t.ContactId); - e.HasIndex(t => new { t.RequesterType, t.RequesterId }); - e.HasIndex(t => t.CreatedAt); - e.HasQueryFilter(t => t.DeletedAt == null); - - e.HasOne(t => t.Department).WithMany(d => d.Tickets).HasForeignKey(t => t.DepartmentId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(t => t.SlaPolicy).WithMany(s => s.Tickets).HasForeignKey(t => t.SlaPolicyId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(t => t.MergedIntoTicket).WithMany().HasForeignKey(t => t.MergedIntoId).OnDelete(DeleteBehavior.Restrict); - e.HasMany(t => t.Replies).WithOne(r => r.Ticket).HasForeignKey(r => r.TicketId).OnDelete(DeleteBehavior.Cascade); - e.HasMany(t => t.Activities).WithOne(a => a.Ticket).HasForeignKey(a => a.TicketId).OnDelete(DeleteBehavior.Cascade); - e.HasMany(t => t.SideConversations).WithOne(s => s.Ticket).HasForeignKey(s => s.TicketId).OnDelete(DeleteBehavior.Cascade); - e.HasMany(t => t.LinksAsParent).WithOne(l => l.ParentTicket).HasForeignKey(l => l.ParentTicketId).OnDelete(DeleteBehavior.Cascade); - e.HasMany(t => t.LinksAsChild).WithOne(l => l.ChildTicket).HasForeignKey(l => l.ChildTicketId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(t => t.SatisfactionRating).WithOne(r => r.Ticket).HasForeignKey(r => r.TicketId); - e.HasOne(t => t.Contact).WithMany().HasForeignKey(t => t.ContactId).OnDelete(DeleteBehavior.SetNull); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}contacts"); - e.HasIndex(c => c.Email).IsUnique(); - e.HasIndex(c => c.UserId); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}replies"); - e.HasIndex(r => r.TicketId); - e.HasIndex(r => r.MessageId); - e.HasQueryFilter(r => r.DeletedAt == null); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}attachments"); - e.HasIndex(a => new { a.AttachableType, a.AttachableId }); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}ticket_activities"); - e.HasIndex(a => a.TicketId); - e.HasIndex(a => a.CreatedAt); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}ticket_statuses"); - e.HasIndex(s => s.Slug).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}ticket_links"); - e.HasIndex(l => new { l.ParentTicketId, l.ChildTicketId }); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}ticket_tag"); - e.HasKey(tt => new { tt.TicketId, tt.TagId }); - e.HasOne(tt => tt.Ticket).WithMany().HasForeignKey(tt => tt.TicketId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(tt => tt.Tag).WithMany().HasForeignKey(tt => tt.TagId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}tags"); - e.HasIndex(t => t.Slug).IsUnique(); - e.HasMany(t => t.Tickets).WithMany(t => t.Tags) - .UsingEntity( - j => j.HasOne(tt => tt.Ticket).WithMany().HasForeignKey(tt => tt.TicketId), - j => j.HasOne(tt => tt.Tag).WithMany().HasForeignKey(tt => tt.TagId)); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}departments"); - e.HasIndex(d => d.Slug).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}satisfaction_ratings"); - e.HasIndex(r => r.TicketId).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}sla_policies"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}escalation_rules"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}business_schedules"); - e.HasMany(b => b.Holidays).WithOne(h => h.Schedule).HasForeignKey(h => h.ScheduleId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}holidays"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}agent_profiles"); - e.HasIndex(a => a.UserId).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}agent_capacity"); - e.HasIndex(a => new { a.UserId, a.Channel }).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}skills"); - e.HasIndex(s => s.Slug).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}agent_skill"); - e.HasKey(a => new { a.UserId, a.SkillId }); - e.HasOne(a => a.Skill).WithMany(s => s.AgentSkills).HasForeignKey(a => a.SkillId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}canned_responses"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}macros"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}side_conversations"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}side_conversation_replies"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}inbound_emails"); - e.HasIndex(i => i.MessageId); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}roles"); - e.HasIndex(r => r.Slug).IsUnique(); - e.HasMany(r => r.Permissions).WithMany(p => p.Roles) - .UsingEntity( - j => j.HasOne(rp => rp.Permission).WithMany().HasForeignKey(rp => rp.PermissionId), - j => j.HasOne(rp => rp.Role).WithMany().HasForeignKey(rp => rp.RoleId)); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}permissions"); - e.HasIndex(p => p.Slug).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}role_permission"); - e.HasKey(rp => new { rp.RoleId, rp.PermissionId }); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}role_user"); - e.HasKey(ru => new { ru.RoleId, ru.UserId }); - e.HasOne(ru => ru.Role).WithMany(r => r.Users).HasForeignKey(ru => ru.RoleId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}api_tokens"); - e.HasIndex(a => a.TokenHash).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}webhooks"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}webhook_deliveries"); - e.HasOne(d => d.Webhook).WithMany(w => w.Deliveries).HasForeignKey(d => d.WebhookId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}audit_logs"); - e.HasIndex(a => new { a.EntityType, a.EntityId }); - e.HasIndex(a => a.UserId); - e.HasIndex(a => a.CreatedAt); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}plugins"); - e.HasIndex(p => p.Slug).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}custom_fields"); - e.HasIndex(f => f.Slug).IsUnique(); - e.HasMany(f => f.Values).WithOne(v => v.CustomField).HasForeignKey(v => v.CustomFieldId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}custom_field_values"); - e.HasIndex(v => new { v.EntityType, v.EntityId }); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}custom_objects"); - e.HasIndex(o => o.Slug).IsUnique(); - e.HasMany(o => o.Records).WithOne(r => r.Object).HasForeignKey(r => r.ObjectId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}custom_object_records"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}import_jobs"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}import_source_maps"); - e.HasIndex(m => new { m.ImportJobId, m.EntityType, m.SourceId }) - .IsUnique() - .HasDatabaseName("IX_import_source_maps_unique_source"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}settings"); - e.HasIndex(s => s.Key).IsUnique(); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}automations"); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}saved_views"); - }); - - modelBuilder.Entity
(e => - { - e.ToTable($"{prefix}articles"); - e.HasIndex(a => a.Slug).IsUnique(); - e.HasOne(a => a.Category).WithMany(c => c.Articles).HasForeignKey(a => a.CategoryId).OnDelete(DeleteBehavior.SetNull); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}article_categories"); - e.HasIndex(c => c.Slug).IsUnique(); - e.HasOne(c => c.Parent).WithMany(c => c.Children).HasForeignKey(c => c.ParentId).OnDelete(DeleteBehavior.Restrict); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}chat_sessions"); - e.HasIndex(s => s.TicketId); - e.HasIndex(s => s.Status); - e.HasIndex(s => s.AgentId); - e.HasOne(s => s.Ticket).WithMany(t => t.ChatSessions).HasForeignKey(s => s.TicketId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(s => s.Department).WithMany().HasForeignKey(s => s.DepartmentId).OnDelete(DeleteBehavior.SetNull); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}chat_routing_rules"); - e.HasIndex(r => r.Priority); - e.HasOne(r => r.Department).WithMany().HasForeignKey(r => r.DepartmentId).OnDelete(DeleteBehavior.SetNull); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}workflows"); - e.HasMany(w => w.WorkflowLogs).WithOne(l => l.Workflow).HasForeignKey(l => l.WorkflowId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.ToTable($"{prefix}workflow_logs"); - e.HasOne(l => l.Ticket).WithMany().HasForeignKey(l => l.TicketId).OnDelete(DeleteBehavior.Cascade); - e.Ignore(l => l.Event); - e.Ignore(l => l.WorkflowName); - e.Ignore(l => l.TicketReference); - e.Ignore(l => l.Matched); - e.Ignore(l => l.ActionsExecutedCount); - e.Ignore(l => l.ActionDetails); - e.Ignore(l => l.DurationMs); - e.Ignore(l => l.Status); - }); + EscalatedModelConfiguration.Configure(modelBuilder); } } diff --git a/src/Escalated/Data/EscalatedDbContextFactory.cs b/src/Escalated/Data/EscalatedDbContextFactory.cs new file mode 100644 index 0000000..c7e3436 --- /dev/null +++ b/src/Escalated/Data/EscalatedDbContextFactory.cs @@ -0,0 +1,18 @@ +using Escalated.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Escalated.Data; + +/// Design-time provider for dotnet-ef scaffolding (skills parity migration). +public sealed class EscalatedDbContextFactory : IDesignTimeDbContextFactory +{ + public EscalatedDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={Path.Combine(Path.GetTempPath(), "escalated-dotnet-ef.design.db")}") + .Options; + + return new EscalatedDbContext(options); + } +} diff --git a/src/Escalated/Data/EscalatedModelConfiguration.cs b/src/Escalated/Data/EscalatedModelConfiguration.cs new file mode 100644 index 0000000..101c6a1 --- /dev/null +++ b/src/Escalated/Data/EscalatedModelConfiguration.cs @@ -0,0 +1,369 @@ +using Escalated.Models; +using Microsoft.EntityFrameworkCore; + +namespace Escalated.Data; + +/// Shared Fluent API for and EF design-time snapshots/migrations. +public static class EscalatedModelConfiguration +{ + public static void Configure(ModelBuilder modelBuilder) + { + // Table names with prefix + const string prefix = "escalated_"; + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}tickets"); + e.HasIndex(t => t.Reference).IsUnique(); + e.HasIndex(t => t.Status); + e.HasIndex(t => t.Priority); + e.HasIndex(t => t.AssignedTo); + e.HasIndex(t => t.DepartmentId); + e.HasIndex(t => t.GuestToken); + e.HasIndex(t => t.GuestEmail); + e.HasIndex(t => t.ContactId); + e.HasIndex(t => new { t.RequesterType, t.RequesterId }); + e.HasIndex(t => t.CreatedAt); + e.HasQueryFilter(t => t.DeletedAt == null); + + e.HasOne(t => t.Department).WithMany(d => d.Tickets).HasForeignKey(t => t.DepartmentId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(t => t.SlaPolicy).WithMany(s => s.Tickets).HasForeignKey(t => t.SlaPolicyId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(t => t.MergedIntoTicket).WithMany().HasForeignKey(t => t.MergedIntoId).OnDelete(DeleteBehavior.Restrict); + e.HasMany(t => t.Replies).WithOne(r => r.Ticket).HasForeignKey(r => r.TicketId).OnDelete(DeleteBehavior.Cascade); + e.HasMany(t => t.Activities).WithOne(a => a.Ticket).HasForeignKey(a => a.TicketId).OnDelete(DeleteBehavior.Cascade); + e.HasMany(t => t.SideConversations).WithOne(s => s.Ticket).HasForeignKey(s => s.TicketId).OnDelete(DeleteBehavior.Cascade); + e.HasMany(t => t.LinksAsParent).WithOne(l => l.ParentTicket).HasForeignKey(l => l.ParentTicketId).OnDelete(DeleteBehavior.Cascade); + e.HasMany(t => t.LinksAsChild).WithOne(l => l.ChildTicket).HasForeignKey(l => l.ChildTicketId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(t => t.SatisfactionRating).WithOne(r => r.Ticket).HasForeignKey(r => r.TicketId); + e.HasOne(t => t.Contact).WithMany().HasForeignKey(t => t.ContactId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}contacts"); + e.HasIndex(c => c.Email).IsUnique(); + e.HasIndex(c => c.UserId); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}replies"); + e.HasIndex(r => r.TicketId); + e.HasIndex(r => r.MessageId); + e.HasQueryFilter(r => r.DeletedAt == null); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}attachments"); + e.HasIndex(a => new { a.AttachableType, a.AttachableId }); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}ticket_activities"); + e.HasIndex(a => a.TicketId); + e.HasIndex(a => a.CreatedAt); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}ticket_statuses"); + e.HasIndex(s => s.Slug).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}ticket_links"); + e.HasIndex(l => new { l.ParentTicketId, l.ChildTicketId }); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}ticket_tag"); + e.HasKey(tt => new { tt.TicketId, tt.TagId }); + e.HasOne(tt => tt.Ticket).WithMany().HasForeignKey(tt => tt.TicketId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(tt => tt.Tag).WithMany().HasForeignKey(tt => tt.TagId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}tags"); + e.HasIndex(t => t.Slug).IsUnique(); + e.HasMany(t => t.Tickets).WithMany(t => t.Tags) + .UsingEntity( + j => j.HasOne(tt => tt.Ticket).WithMany().HasForeignKey(tt => tt.TicketId), + j => j.HasOne(tt => tt.Tag).WithMany().HasForeignKey(tt => tt.TagId)); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}departments"); + e.HasIndex(d => d.Slug).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}satisfaction_ratings"); + e.HasIndex(r => r.TicketId).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}sla_policies"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}escalation_rules"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}business_schedules"); + e.HasMany(b => b.Holidays).WithOne(h => h.Schedule).HasForeignKey(h => h.ScheduleId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}holidays"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}agent_profiles"); + e.HasIndex(a => a.UserId).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}agent_capacity"); + e.HasIndex(a => new { a.UserId, a.Channel }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}skills"); + e.HasIndex(s => s.Slug).IsUnique(); + e.HasMany(s => s.RoutingTags).WithOne(rt => rt.Skill).HasForeignKey(rt => rt.SkillId).OnDelete(DeleteBehavior.Cascade); + e.HasMany(s => s.RoutingDepartments).WithOne(rd => rd.Skill).HasForeignKey(rd => rd.SkillId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}skill_routing_tags"); + e.HasOne(rt => rt.Tag).WithMany().HasForeignKey(rt => rt.TagId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(rt => new { rt.SkillId, rt.TagId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}skill_routing_departments"); + e.HasOne(rd => rd.Department).WithMany().HasForeignKey(rd => rd.DepartmentId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(rd => new { rd.SkillId, rd.DepartmentId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable( + $"{prefix}agent_skill", + tb => + tb.HasCheckConstraint( + "CK_escalated_agent_skill_proficiency", + $"{nameof(AgentSkill.Proficiency)} BETWEEN 1 AND 5")); + e.HasOne(a => a.Skill).WithMany(s => s.AgentSkills).HasForeignKey(a => a.SkillId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(a => new { a.UserId, a.SkillId }).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}canned_responses"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}macros"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}side_conversations"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}side_conversation_replies"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}inbound_emails"); + e.HasIndex(i => i.MessageId); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}roles"); + e.HasIndex(r => r.Slug).IsUnique(); + e.HasMany(r => r.Permissions).WithMany(p => p.Roles) + .UsingEntity( + j => j.HasOne(rp => rp.Permission).WithMany().HasForeignKey(rp => rp.PermissionId), + j => j.HasOne(rp => rp.Role).WithMany().HasForeignKey(rp => rp.RoleId)); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}permissions"); + e.HasIndex(p => p.Slug).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}role_permission"); + e.HasKey(rp => new { rp.RoleId, rp.PermissionId }); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}role_user"); + e.HasKey(ru => new { ru.RoleId, ru.UserId }); + e.HasOne(ru => ru.Role).WithMany(r => r.Users).HasForeignKey(ru => ru.RoleId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}api_tokens"); + e.HasIndex(a => a.TokenHash).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}webhooks"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}webhook_deliveries"); + e.HasOne(d => d.Webhook).WithMany(w => w.Deliveries).HasForeignKey(d => d.WebhookId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}audit_logs"); + e.HasIndex(a => new { a.EntityType, a.EntityId }); + e.HasIndex(a => a.UserId); + e.HasIndex(a => a.CreatedAt); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}plugins"); + e.HasIndex(p => p.Slug).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}custom_fields"); + e.HasIndex(f => f.Slug).IsUnique(); + e.HasMany(f => f.Values).WithOne(v => v.CustomField).HasForeignKey(v => v.CustomFieldId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}custom_field_values"); + e.HasIndex(v => new { v.EntityType, v.EntityId }); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}custom_objects"); + e.HasIndex(o => o.Slug).IsUnique(); + e.HasMany(o => o.Records).WithOne(r => r.Object).HasForeignKey(r => r.ObjectId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}custom_object_records"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}import_jobs"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}import_source_maps"); + e.HasIndex(m => new { m.ImportJobId, m.EntityType, m.SourceId }) + .IsUnique() + .HasDatabaseName("IX_import_source_maps_unique_source"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}settings"); + e.HasIndex(s => s.Key).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}automations"); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}saved_views"); + }); + + modelBuilder.Entity
(e => + { + e.ToTable($"{prefix}articles"); + e.HasIndex(a => a.Slug).IsUnique(); + e.HasOne(a => a.Category).WithMany(c => c.Articles).HasForeignKey(a => a.CategoryId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}article_categories"); + e.HasIndex(c => c.Slug).IsUnique(); + e.HasOne(c => c.Parent).WithMany(c => c.Children).HasForeignKey(c => c.ParentId).OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}chat_sessions"); + e.HasIndex(s => s.TicketId); + e.HasIndex(s => s.Status); + e.HasIndex(s => s.AgentId); + e.HasOne(s => s.Ticket).WithMany(t => t.ChatSessions).HasForeignKey(s => s.TicketId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(s => s.Department).WithMany().HasForeignKey(s => s.DepartmentId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}chat_routing_rules"); + e.HasIndex(r => r.Priority); + e.HasOne(r => r.Department).WithMany().HasForeignKey(r => r.DepartmentId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}workflows"); + e.HasMany(w => w.WorkflowLogs).WithOne(l => l.Workflow).HasForeignKey(l => l.WorkflowId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.ToTable($"{prefix}workflow_logs"); + e.HasOne(l => l.Ticket).WithMany().HasForeignKey(l => l.TicketId).OnDelete(DeleteBehavior.Cascade); + e.Ignore(l => l.Event); + e.Ignore(l => l.WorkflowName); + e.Ignore(l => l.TicketReference); + e.Ignore(l => l.Matched); + e.Ignore(l => l.ActionsExecutedCount); + e.Ignore(l => l.ActionDetails); + e.Ignore(l => l.DurationMs); + e.Ignore(l => l.Status); + }); + } +} diff --git a/src/Escalated/Dtos/Admin/SkillAdminDtos.cs b/src/Escalated/Dtos/Admin/SkillAdminDtos.cs new file mode 100644 index 0000000..1a4853a --- /dev/null +++ b/src/Escalated/Dtos/Admin/SkillAdminDtos.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace Escalated.Dtos.Admin; + +public sealed class CreateSkillDto +{ + [Required] + [MaxLength(100)] + public required string Name { get; init; } + + [MaxLength(2000)] + public string? Description { get; init; } + + public int[]? RoutingTagIds { get; init; } + + public int[]? RoutingDepartmentIds { get; init; } + + public AgentSkillEntryDto[]? Agents { get; init; } +} + +public sealed class UpdateSkillDto +{ + [Required] + [MaxLength(100)] + public required string Name { get; init; } + + [MaxLength(2000)] + public string? Description { get; init; } + + public int[]? RoutingTagIds { get; init; } + + public int[]? RoutingDepartmentIds { get; init; } + + public AgentSkillEntryDto[]? Agents { get; init; } +} + +public sealed class AgentSkillEntryDto +{ + [Range(1, int.MaxValue)] + public required int UserId { get; init; } + + [Range(1, 5)] + public required int Proficiency { get; init; } +} diff --git a/src/Escalated/Escalated.csproj b/src/Escalated/Escalated.csproj index 325d5a3..4f7afe5 100644 --- a/src/Escalated/Escalated.csproj +++ b/src/Escalated/Escalated.csproj @@ -20,9 +20,14 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + + diff --git a/src/Escalated/Migrations/20260517143000_SkillManagementParity.cs b/src/Escalated/Migrations/20260517143000_SkillManagementParity.cs new file mode 100644 index 0000000..7389c9e --- /dev/null +++ b/src/Escalated/Migrations/20260517143000_SkillManagementParity.cs @@ -0,0 +1,235 @@ +using Escalated.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Escalated.Migrations; + +/// +/// Skills parity (#58): explicit routing pivots + surrogate key, timestamps, proficiency 1..5. +/// Agent-skill DDL is intentionally provider-scripted — See . +/// +[DbContext(typeof(EscalatedDbContext))] +[Migration("20260517143000")] +public partial class SkillManagementParity : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "escalated_skill_routing_tags", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .Annotation("SqlServer:Identity", "1, 1") + .Annotation("Sqlite:Autoincrement", true), + SkillId = table.Column(type: "int", nullable: false), + TagId = table.Column(type: "int", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_escalated_skill_routing_tags", x => x.Id); + table.ForeignKey( + name: "FK_escalated_skill_routing_tags_escalated_skills_SkillId", + column: x => x.SkillId, + principalTable: "escalated_skills", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_escalated_skill_routing_tags_escalated_tags_TagId", + column: x => x.TagId, + principalTable: "escalated_tags", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "escalated_skill_routing_departments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .Annotation("SqlServer:Identity", "1, 1") + .Annotation("Sqlite:Autoincrement", true), + SkillId = table.Column(type: "int", nullable: false), + DepartmentId = table.Column(type: "int", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_escalated_skill_routing_departments", x => x.Id); + table.ForeignKey( + name: "FK_escalated_skill_routing_departments_escalated_departments_DepartmentId", + column: x => x.DepartmentId, + principalTable: "escalated_departments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_escalated_skill_routing_departments_escalated_skills_SkillId", + column: x => x.SkillId, + principalTable: "escalated_skills", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_escalated_skill_routing_departments_SkillId_DepartmentId", + table: "escalated_skill_routing_departments", + columns: ["SkillId", "DepartmentId"], + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_escalated_skill_routing_tags_SkillId_TagId", + table: "escalated_skill_routing_tags", + columns: ["SkillId", "TagId"], + unique: true); + + MigrateAgentSkills(migrationBuilder); + } + + private static void MigrateAgentSkills(MigrationBuilder migrationBuilder) + { + switch (migrationBuilder.ActiveProvider) + { + case "Microsoft.EntityFrameworkCore.Sqlite": + migrationBuilder.Sql( + """ + + CREATE TABLE escalated_agent_skill_par ( + Id INTEGER NOT NULL CONSTRAINT PK_escalated_agent_skill PRIMARY KEY AUTOINCREMENT, + UserId INTEGER NOT NULL, + SkillId INTEGER NOT NULL, + Proficiency INTEGER NOT NULL DEFAULT 3, + CreatedAt TEXT NOT NULL, + UpdatedAt TEXT NOT NULL, + CONSTRAINT FK_escalated_agent_skill_escalated_skills_SkillId FOREIGN KEY (SkillId) REFERENCES escalated_skills (Id) ON DELETE CASCADE, + CONSTRAINT CK_escalated_agent_skill_proficiency CHECK (Proficiency BETWEEN 1 AND 5), + UNIQUE (UserId, SkillId) + ); + + INSERT INTO escalated_agent_skill_par (UserId, SkillId, Proficiency, CreatedAt, UpdatedAt) + SELECT + UserId, + SkillId, + CASE lower(trim(ifnull(Proficiency, ''))) + WHEN 'beginner' THEN 1 + WHEN 'intermediate' THEN 3 + WHEN 'expert' THEN 5 + ELSE 3 END, + datetime('now'), + datetime('now') + FROM escalated_agent_skill; + + DROP TABLE escalated_agent_skill; + + ALTER TABLE escalated_agent_skill_par RENAME TO escalated_agent_skill; + + CREATE UNIQUE INDEX IX_escalated_agent_skill_UserId_SkillId + ON escalated_agent_skill (UserId, SkillId); + CREATE INDEX IX_escalated_agent_skill_SkillId + ON escalated_agent_skill (SkillId); + """); + break; + + case "Npgsql.EntityFrameworkCore.PostgreSQL": + migrationBuilder.Sql( + """ + + ALTER TABLE escalated_agent_skill DROP CONSTRAINT IF EXISTS "FK_escalated_agent_skill_escalated_skills_SkillId"; + DROP INDEX IF EXISTS "IX_escalated_agent_skill_SkillId"; + DROP INDEX IF EXISTS "IX_escalated_agent_skill_UserId_SkillId"; + + ALTER TABLE escalated_agent_skill RENAME TO escalated_agent_skill_legacy; + + CREATE TABLE escalated_agent_skill ( + "Id" SERIAL CONSTRAINT "PK_escalated_agent_skill" PRIMARY KEY, + "UserId" INTEGER NOT NULL, + "SkillId" INTEGER NOT NULL, + "Proficiency" INTEGER NOT NULL DEFAULT 3, + CONSTRAINT "CK_escalated_agent_skill_proficiency" CHECK ("Proficiency" BETWEEN 1 AND 5), + "CreatedAt" TIMESTAMPTZ NOT NULL, + "UpdatedAt" TIMESTAMPTZ NOT NULL, + CONSTRAINT "FK_escalated_agent_skill_escalated_skills_SkillId" + FOREIGN KEY ("SkillId") REFERENCES escalated_skills ("Id") ON DELETE CASCADE + ); + + INSERT INTO escalated_agent_skill ("UserId", "SkillId", "Proficiency", "CreatedAt", "UpdatedAt") + SELECT + "UserId", + "SkillId", + CASE lower(trim(cast("Proficiency" AS text))) + WHEN 'beginner' THEN 1 + WHEN 'intermediate' THEN 3 + WHEN 'expert' THEN 5 + ELSE 3 END, + timezone('utc', now()), + timezone('utc', now()) + FROM escalated_agent_skill_legacy; + + DROP TABLE escalated_agent_skill_legacy; + + CREATE INDEX "IX_escalated_agent_skill_SkillId" ON escalated_agent_skill ("SkillId"); + CREATE UNIQUE INDEX "IX_escalated_agent_skill_UserId_SkillId" + ON escalated_agent_skill ("UserId","SkillId"); + """); + break; + + case "Microsoft.EntityFrameworkCore.SqlServer": + migrationBuilder.Sql( + """ + + IF OBJECT_ID(N'dbo.escalated_agent_skill', N'U') IS NULL RETURN; + + IF OBJECT_ID(N'FK_escalated_agent_skill_escalated_skills_SkillId', N'F') IS NOT NULL + ALTER TABLE dbo.escalated_agent_skill DROP CONSTRAINT [FK_escalated_agent_skill_escalated_skills_SkillId]; + + EXEC sp_rename 'dbo.escalated_agent_skill', 'escalated_agent_skill_legacy'; + + CREATE TABLE dbo.escalated_agent_skill ( + Id INT IDENTITY CONSTRAINT PK_escalated_agent_skill PRIMARY KEY, + UserId INT NOT NULL, + SkillId INT NOT NULL, + Proficiency INT NOT NULL CONSTRAINT CK_escalated_agent_skill_proficiency + CHECK (Proficiency BETWEEN 1 AND 5), + CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_esc_C DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIME2 NOT NULL CONSTRAINT DF_esc_U DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_escalated_agent_skill_escalated_skills_SkillId FOREIGN KEY(SkillId) + REFERENCES dbo.escalated_skills(Id) ON DELETE CASCADE); + + INSERT INTO dbo.escalated_agent_skill (UserId,SkillId,Proficiency,CreatedAt,UpdatedAt) + SELECT + UserId, + SkillId, + CASE WHEN TRY_CONVERT(int, Proficiency) BETWEEN 1 AND 5 THEN TRY_CONVERT(int, Proficiency) + ELSE CASE lower(ltrim(rtrim(CONVERT(NVARCHAR(64), Proficiency)))) + WHEN N'beginner' THEN 1 + WHEN N'intermediate' THEN 3 + WHEN N'expert' THEN 5 ELSE 3 END END, + SYSUTCDATETIME(), + SYSUTCDATETIME() + FROM dbo.escalated_agent_skill_legacy; + + DROP TABLE dbo.escalated_agent_skill_legacy; + + ALTER TABLE dbo.escalated_agent_skill DROP CONSTRAINT DF_esc_C; + ALTER TABLE dbo.escalated_agent_skill DROP CONSTRAINT DF_esc_U; + + CREATE UNIQUE INDEX IX_escalated_agent_skill_UserId_SkillId + ON dbo.escalated_agent_skill(UserId,SkillId); + CREATE INDEX IX_escalated_agent_skill_SkillId ON dbo.escalated_agent_skill(SkillId); + """); + break; + + default: + throw new NotSupportedException( + $"Skills parity migration reshapes agent_skill for Sqlite / Npgsql / SqlServer only. Actual provider: '{migrationBuilder.ActiveProvider}'."); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "escalated_skill_routing_departments"); + migrationBuilder.DropTable(name: "escalated_skill_routing_tags"); + // AgentSkill reshape is not reversible from code — rollback requires a bespoke SQL restore (#58). + } +} diff --git a/src/Escalated/Migrations/EscalatedDbContextModelSnapshot.cs b/src/Escalated/Migrations/EscalatedDbContextModelSnapshot.cs new file mode 100644 index 0000000..d09332c --- /dev/null +++ b/src/Escalated/Migrations/EscalatedDbContextModelSnapshot.cs @@ -0,0 +1,19 @@ +using Escalated.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Escalated.Migrations; + +/// Design-time EF model baseline (shared fluent API with ). +[DbContext(typeof(EscalatedDbContext))] +public partial class EscalatedDbContextModelSnapshot : ModelSnapshot +{ + protected override void BuildModel(ModelBuilder modelBuilder) + { + EscalatedModelConfiguration.Configure(modelBuilder); + +#pragma warning disable CS0618 // Mirrors EF-generated snapshots + modelBuilder.HasAnnotation("ProductVersion", "9.0.15"); +#pragma warning restore CS0618 + } +} diff --git a/src/Escalated/Models/LegacySkillProficiency.cs b/src/Escalated/Models/LegacySkillProficiency.cs new file mode 100644 index 0000000..72f1c1d --- /dev/null +++ b/src/Escalated/Models/LegacySkillProficiency.cs @@ -0,0 +1,27 @@ +namespace Escalated.Models; + +/// +/// Maps legacy proficiency strings persisted before the canonical 1..5 numeric scale (#58). +/// +public static class LegacySkillProficiency +{ + public static int Parse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return 3; + } + + switch (raw.Trim().ToLowerInvariant()) + { + case "beginner": + return 1; + case "intermediate": + return 3; + case "expert": + return 5; + default: + return 3; + } + } +} diff --git a/src/Escalated/Models/Skill.cs b/src/Escalated/Models/Skill.cs index ce560a2..dce03fe 100644 --- a/src/Escalated/Models/Skill.cs +++ b/src/Escalated/Models/Skill.cs @@ -23,6 +23,10 @@ public class Skill public ICollection AgentSkills { get; set; } = new List(); + public ICollection RoutingTags { get; set; } = new List(); + + public ICollection RoutingDepartments { get; set; } = new List(); + public static string GenerateSlug(string name) { var slug = name.ToLowerInvariant(); @@ -34,11 +38,43 @@ public static string GenerateSlug(string name) public class AgentSkill { + [Key] + public int Id { get; set; } + public int UserId { get; set; } public int SkillId { get; set; } - [MaxLength(50)] - public string? Proficiency { get; set; } // beginner, intermediate, expert + /// 1 (beginner) .. 5 (expert). + [Range(1, 5)] + public int Proficiency { get; set; } = 3; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public Skill? Skill { get; set; } } + +public class SkillRoutingTag +{ + [Key] + public int Id { get; set; } + + public int SkillId { get; set; } + public Skill? Skill { get; set; } + + public int TagId { get; set; } + public Tag? Tag { get; set; } +} + +public class SkillRoutingDepartment +{ + [Key] + public int Id { get; set; } + + public int SkillId { get; set; } + public Skill? Skill { get; set; } + + public int DepartmentId { get; set; } + public Department? Department { get; set; } +} diff --git a/src/Escalated/Services/SkillRoutingService.cs b/src/Escalated/Services/SkillRoutingService.cs index d791921..572d5c9 100644 --- a/src/Escalated/Services/SkillRoutingService.cs +++ b/src/Escalated/Services/SkillRoutingService.cs @@ -15,58 +15,85 @@ public SkillRoutingService(EscalatedDbContext db) } /// - /// Find agents with skills matching ticket tags, sorted by current workload (ascending). + /// Rank agents eligible under explicit routing (tags / departments): they must hold + /// every matching skill row. Ranking is proficiency sum (required skills only) + /// descending, then current open workload ascending. /// public async Task> FindMatchingAgentIdsAsync(Ticket ticket, CancellationToken ct = default) { - // Get the ticket's tag names - var tagNames = await _db.TicketTags + var tagIds = await _db.TicketTags .Where(tt => tt.TicketId == ticket.Id) - .Join(_db.Tags, tt => tt.TagId, t => t.Id, (tt, t) => t.Name) + .Select(tt => tt.TagId) + .Distinct() .ToListAsync(ct); - if (!tagNames.Any()) return new List(); + var skillFromTags = tagIds.Count == 0 + ? new List() + : await _db.SkillRoutingTags + .Where(rt => tagIds.Contains(rt.TagId)) + .Select(rt => rt.SkillId) + .Distinct() + .ToListAsync(ct); - // Find skills matching tag names - var skillIds = await _db.Skills - .Where(s => tagNames.Contains(s.Name)) - .Select(s => s.Id) - .ToListAsync(ct); + var skillFromDept = new List(); + if (ticket.DepartmentId is { } deptId) + { + skillFromDept = await _db.SkillRoutingDepartments + .Where(rd => rd.DepartmentId == deptId) + .Select(rd => rd.SkillId) + .Distinct() + .ToListAsync(ct); + } - if (!skillIds.Any()) return new List(); + var requiredSkillIds = skillFromTags.Union(skillFromDept).Distinct().ToList(); + var requiredCount = requiredSkillIds.Count; + if (requiredCount == 0) + { + return new List(); + } - // Find agents with these skills - var agentIds = await _db.AgentSkills - .Where(a => skillIds.Contains(a.SkillId)) - .Select(a => a.UserId) - .Distinct() + var candidateRows = await _db.AgentSkills + .AsNoTracking() + .Where(a => requiredSkillIds.Contains(a.SkillId)) + .GroupBy(a => a.UserId) + .Where(g => g.Select(a => a.SkillId).Distinct().Count() == requiredCount) + .Select(g => new + { + UserId = g.Key, + ProficiencySum = g.Sum(a => a.Proficiency), + }) .ToListAsync(ct); - if (!agentIds.Any()) return new List(); - - // Sort by open ticket count (ascending) - var agentLoads = new List<(int UserId, int OpenCount)>(); - foreach (var agentId in agentIds) + if (candidateRows.Count == 0) { - var openCount = await _db.Tickets - .Where(t => t.AssignedTo == agentId) - .Where(t => t.Status != TicketStatus.Resolved && t.Status != TicketStatus.Closed) - .CountAsync(ct); - agentLoads.Add((agentId, openCount)); + return new List(); } - return agentLoads - .OrderBy(a => a.OpenCount) - .Select(a => a.UserId) + var userIds = candidateRows.Select(r => r.UserId).ToList(); + + var openCounts = await _db.Tickets + .Where(t => + t.AssignedTo.HasValue && userIds.Contains(t.AssignedTo.Value) + && t.Status != TicketStatus.Resolved + && t.Status != TicketStatus.Closed) + .GroupBy(t => t.AssignedTo!.Value) + .Select(g => new { UserId = g.Key, Open = g.Count() }) + .ToListAsync(ct); + + var loads = openCounts.ToDictionary(x => x.UserId, x => x.Open); + + return candidateRows + .OrderByDescending(r => r.ProficiencySum) + .ThenBy(r => loads.GetValueOrDefault(r.UserId, 0)) + .ThenBy(r => r.UserId) + .Select(r => r.UserId) .ToList(); } - /// - /// Auto-assign ticket to the best matching agent by skills. - /// + /// Best match (same ordering as list), or null when no eligible router. public async Task FindBestAgentAsync(Ticket ticket, CancellationToken ct = default) { var agents = await FindMatchingAgentIdsAsync(ticket, ct); - return agents.FirstOrDefault(); + return agents.Count == 0 ? null : agents[0]; } } diff --git a/tests/Escalated.Tests/Controllers/AdminSkillsControllerTests.cs b/tests/Escalated.Tests/Controllers/AdminSkillsControllerTests.cs new file mode 100644 index 0000000..f877e80 --- /dev/null +++ b/tests/Escalated.Tests/Controllers/AdminSkillsControllerTests.cs @@ -0,0 +1,242 @@ +using Escalated.Controllers.Admin; +using Escalated.Data; +using Escalated.Dtos.Admin; +using Escalated.Enums; +using Escalated.Models; +using Escalated.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Escalated.Tests.Controllers; + +public class AdminSkillsControllerTests +{ + private sealed class StubUserDirectory : IUserDirectory + { + private readonly List _users = new(); + + public UserDirectoryEntry Add(int id, string? name, string? email) + { + var e = new UserDirectoryEntry(id, name, email); + _users.Add(e); + return e; + } + + public Task ListAsync(string? search, int page, int pageSize, CancellationToken ct = default) + { + IEnumerable q = _users; + return Task.FromResult(new UserDirectoryPage(q.ToList(), q.Count(), page, pageSize)); + } + + public Task FindAsync(int id, CancellationToken ct = default) + => Task.FromResult(_users.FirstOrDefault(u => u.Id == id)); + } + + private static async Task<(AdminSkillsController Ctrl, EscalatedDbContext Db, StubUserDirectory Dir)> SeedControllerAsync() + { + var db = TestHelpers.CreateInMemoryDb(); + var dir = new StubUserDirectory(); + var ctrl = new AdminSkillsController(db, dir); + await SeedAgentsAsync(db, dir); + await SeedRoutingLookups(db); + return (ctrl, db, dir); + } + + private static async Task SeedAgentsAsync(EscalatedDbContext db, StubUserDirectory dir) + { + var agentRole = new Role { Name = "Escalated Agent", Slug = AdminUsersController.AgentRoleSlug, IsSystem = true, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; + db.Roles.Add(agentRole); + await db.SaveChangesAsync(); + + dir.Add(10, "Agnes", "agnes@test"); + dir.Add(11, "Ben", "ben@test"); + db.RoleUsers.Add(new RoleUser { UserId = 10, RoleId = agentRole.Id }); + db.RoleUsers.Add(new RoleUser { UserId = 11, RoleId = agentRole.Id }); + await db.SaveChangesAsync(); + } + + private static async Task<(Tag T, Department D)> SeedRoutingLookups(EscalatedDbContext db) + { + var now = DateTime.UtcNow; + var t = new Tag { Name = "priority", Slug = "priority", CreatedAt = now, UpdatedAt = now }; + var d = new Department { Name = "Support", Slug = "support", IsActive = true, CreatedAt = now, UpdatedAt = now }; + db.Tags.Add(t); + db.Departments.Add(d); + await db.SaveChangesAsync(); + return (t, d); + } + + private static async Task GrantRoleSlugAsync(EscalatedDbContext db, int userId, string slug) + { + var role = await db.Roles.FirstOrDefaultAsync(r => r.Slug == slug); + if (role is null) + { + role = new Role + { + Name = slug, + Slug = slug, + IsSystem = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + db.Roles.Add(role); + await db.SaveChangesAsync(); + } + + db.RoleUsers.Add(new RoleUser { RoleId = role.Id, UserId = userId }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task Index_IndexIsEmptyInitially() + { + var db = TestHelpers.CreateInMemoryDb(); + var ctrl = new AdminSkillsController(db, new StubUserDirectory()); + + var result = await ctrl.Index(); + + var ok = Assert.IsType(result); + var body = Assert.IsType(ok.Value); + Assert.Empty(body.Skills); + } + + [Fact] + public async Task Create_ReturnsEnvelopeWithLookupsAndZeroSkill() + { + var (ctrl, _, _) = await SeedControllerAsync(); + + var result = await ctrl.Create(); + + var ok = Assert.IsType(result); + var body = Assert.IsType(ok.Value); + Assert.Equal(0, body.Skill.Id); + Assert.Equal(2, body.AvailableAgents.Count); + Assert.NotEmpty(body.AvailableTags); + Assert.NotEmpty(body.AvailableDepartments); + } + + [Fact] + public async Task Store_And_Index_CountsRelationships() + { + var (ctrl, db, _) = await SeedControllerAsync(); + var (tag, dept) = (await db.Tags.FirstAsync(), await db.Departments.FirstAsync()); + + var storeResult = await ctrl.Store( + new CreateSkillDto + { + Name = "OAuth", + Description = "auth", + RoutingTagIds = new[] { tag.Id }, + RoutingDepartmentIds = new[] { dept.Id }, + Agents = new[] + { + new AgentSkillEntryDto { UserId = 10, Proficiency = 5 }, + new AgentSkillEntryDto { UserId = 11, Proficiency = 2 }, + }, + }); + + var created = Assert.IsType(storeResult); + var envelope = Assert.IsType(created.Value); + Assert.True(envelope.Id > 0); + + var ix = Assert.IsType(await ctrl.Index()).Value; + var list = Assert.IsType(ix); + Assert.Single(list.Skills); + var row = list.Skills[0]; + Assert.Equal(envelope.Id, row.Id); + Assert.Equal("OAuth", row.Name); + Assert.Equal(2, row.AgentsCount); + Assert.Equal(1, row.RoutingTagsCount); + Assert.Equal(1, row.RoutingDepartmentsCount); + + var edit = Assert.IsType(await ctrl.Edit(envelope.Id)).Value; + var detail = Assert.IsType(edit); + Assert.Single(detail.Skill.RoutingTagIds); + Assert.Equal(tag.Id, detail.Skill.RoutingTagIds[0]); + Assert.Single(detail.Skill.RoutingDepartmentIds); + Assert.Equal(dept.Id, detail.Skill.RoutingDepartmentIds[0]); + Assert.Equal(2, detail.Skill.Agents.Length); + } + + [Fact] + public async Task Store_ReturnsBadRequest_WhenRoutingTagMissing() + { + var (ctrl, _, _) = await SeedControllerAsync(); + + var res = await ctrl.Store(new CreateSkillDto + { + Name = "X", + RoutingTagIds = new[] { int.MaxValue }, + }); + + Assert.IsType(res); + } + + [Fact] + public async Task Update_AltersAssignments() + { + var (ctrl, db, _) = await SeedControllerAsync(); + await ctrl.Store(new CreateSkillDto { Name = "OnlyTag", RoutingTagIds = new[] { (await db.Tags.FirstAsync()).Id } }); + var id = db.Skills.Single().Id; + + var dept = await db.Departments.FirstAsync(); + await ctrl.Update( + id, + new UpdateSkillDto + { + Name = "Both", + Description = null, + RoutingDepartmentIds = new[] { dept.Id }, + RoutingTagIds = Array.Empty(), + Agents = + new[] + { + new AgentSkillEntryDto { UserId = 10, Proficiency = 4 }, + new AgentSkillEntryDto { UserId = 10, Proficiency = 1 }, // last wins within payload + }, + }); + + var refreshed = Assert.IsType(await ctrl.Edit(id)).Value; + var envelope = Assert.IsType(refreshed); + Assert.Equal("Both", envelope.Skill.Name); + Assert.Single(envelope.Skill.Agents); + Assert.Equal(1, envelope.Skill.Agents.First().Proficiency); + Assert.Contains(dept.Id, envelope.Skill.RoutingDepartmentIds); + } + + [Fact] + public async Task Destroy_RemovesSkill() + { + var (ctrl, db, _) = await SeedControllerAsync(); + await ctrl.Store(new CreateSkillDto { Name = "rm", RoutingTagIds = Array.Empty() }); + var id = db.Skills.Single().Id; + + Assert.IsType(await ctrl.Destroy(id)); + + Assert.False(await db.Skills.AnyAsync()); + Assert.False(await db.AgentSkills.AnyAsync()); + } + + [Fact] + public async Task Edit_Returns404_WhenMissing() + { + var db = TestHelpers.CreateInMemoryDb(); + var ctrl = new AdminSkillsController(db, new StubUserDirectory()); + + Assert.IsType(await ctrl.Edit(int.MaxValue)); + } + + [Fact] + public async Task UniqueSlug_AppendsNumericSuffix_OnCollision() + { + var (ctrl, db, _) = await SeedControllerAsync(); + await ctrl.Store(new CreateSkillDto { Name = "Same Name" }); + + Assert.IsType(await ctrl.Store(new CreateSkillDto { Name = "Same Name" })); + + var slugs = await db.Skills.Select(s => s.Slug).OrderBy(s => s).ToListAsync(); + Assert.Contains("same-name", slugs.First()); + Assert.Contains("same-name-", slugs.Last()); + } +} diff --git a/tests/Escalated.Tests/Models/LegacySkillProficiencyTests.cs b/tests/Escalated.Tests/Models/LegacySkillProficiencyTests.cs new file mode 100644 index 0000000..e99a673 --- /dev/null +++ b/tests/Escalated.Tests/Models/LegacySkillProficiencyTests.cs @@ -0,0 +1,20 @@ +using Escalated.Models; +using Xunit; + +namespace Escalated.Tests.Models; + +public class LegacySkillProficiencyTests +{ + [Theory] + [InlineData(null, 3)] + [InlineData("", 3)] + [InlineData(" ", 3)] + [InlineData("beginner", 1)] + [InlineData("Beginner", 1)] + [InlineData("intermediate", 3)] + [InlineData("expert", 5)] + [InlineData("bogus", 3)] + [InlineData("unknown-level", 3)] + public void Parse_NormalizesHistoricalStrings(string? raw, int expected) + => Assert.Equal(expected, LegacySkillProficiency.Parse(raw)); +} diff --git a/tests/Escalated.Tests/Services/SkillRoutingServiceTests.cs b/tests/Escalated.Tests/Services/SkillRoutingServiceTests.cs new file mode 100644 index 0000000..282d5aa --- /dev/null +++ b/tests/Escalated.Tests/Services/SkillRoutingServiceTests.cs @@ -0,0 +1,242 @@ +using Escalated.Data; +using Escalated.Enums; +using Escalated.Models; +using Escalated.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Escalated.Tests.Services; + +public class SkillRoutingServiceTests +{ + private sealed record SkillRow(int UserId, int? TagsProf, int? DeptsProf); + + private static async Task<(EscalatedDbContext Db, Ticket Ticket)> SeedAsync(string ticketRef, IList abilities) + { + var db = TestHelpers.CreateInMemoryDb(); + var now = DateTime.UtcNow; + var tag = new Tag { Name = $"{ticketRef}-t", Slug = $"{ticketRef}-t", CreatedAt = now, UpdatedAt = now }; + var dept = new Department { Name = $"{ticketRef}-d", Slug = $"{ticketRef}-d", IsActive = true, CreatedAt = now, UpdatedAt = now }; + db.Tags.Add(tag); + db.Departments.Add(dept); + + var sTag = new Skill { Name = $"{ticketRef}-st", Slug = $"{ticketRef}-st", CreatedAt = now, UpdatedAt = now }; + var sDept = new Skill { Name = $"{ticketRef}-sd", Slug = $"{ticketRef}-sd", CreatedAt = now, UpdatedAt = now }; + db.Skills.AddRange(sTag, sDept); + db.SaveChanges(); + + db.SkillRoutingTags.Add(new SkillRoutingTag { SkillId = sTag.Id, TagId = tag.Id }); + db.SkillRoutingDepartments.Add(new SkillRoutingDepartment { SkillId = sDept.Id, DepartmentId = dept.Id }); + + foreach (var a in abilities) + { + if (a.TagsProf is { } tp) + { + db.AgentSkills.Add(new AgentSkill + { + UserId = a.UserId, + SkillId = sTag.Id, + Proficiency = tp, + CreatedAt = now, + UpdatedAt = now, + }); + } + + if (a.DeptsProf is { } dp) + { + db.AgentSkills.Add(new AgentSkill + { + UserId = a.UserId, + SkillId = sDept.Id, + Proficiency = dp, + CreatedAt = now, + UpdatedAt = now, + }); + } + } + + var ticket = new Ticket + { + Reference = ticketRef, + Subject = "routing", + Status = TicketStatus.Open, + Priority = TicketPriority.Medium, + DepartmentId = dept.Id, + CreatedAt = now, + UpdatedAt = now, + }; + + ticket.Tags.Add(tag); + + db.Tickets.Add(ticket); + db.SaveChanges(); + + var tracked = await db.Tickets.Include(t => t.Tags).SingleAsync(t => t.Reference == ticketRef); + return (db, tracked); + } + + [Fact] + public async Task RequiresEveryMatchedSkillAcrossTagAndDepartmentSkills() + { + var (db, ticket) = await SeedAsync( + "ESC-R1", + new SkillRow[] + { + new(10, 5, 5), + new(11, 5, null), + }); + + try + { + var ids = await new SkillRoutingService(db).FindMatchingAgentIdsAsync(ticket); + Assert.Single(ids); + Assert.Equal(10, ids[0]); + } + finally + { + await db.DisposeAsync(); + } + } + + [Fact] + public async Task OrdersByDescendingProficiencyThenAscOpenWorkload() + { + var (db, ticket) = await SeedAsync( + "ESC-R2", + new SkillRow[] + { + new(501, 5, 5), + new(502, 2, 2), + }); + + try + { + var now = DateTime.UtcNow; + for (var i = 0; i < 3; i++) + { + db.Tickets.Add(new Ticket + { + Reference = $"WK501-{ticket.Reference}-{i}", + Subject = "load", + Status = TicketStatus.Open, + Priority = TicketPriority.Low, + AssignedTo = 501, + CreatedAt = now, + UpdatedAt = now, + }); + } + + for (var i = 0; i < 2; i++) + { + db.Tickets.Add(new Ticket + { + Reference = $"WK502-{ticket.Reference}-{i}", + Subject = "load", + Status = TicketStatus.Open, + Priority = TicketPriority.Low, + AssignedTo = 502, + CreatedAt = now, + UpdatedAt = now, + }); + } + + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + ticket = await db.Tickets.AsNoTracking().Include(t => t.Tags).SingleAsync(t => t.Reference == ticket.Reference); + + var service = new SkillRoutingService(db); + var ids = await service.FindMatchingAgentIdsAsync(ticket); + Assert.Equal(new[] { 501, 502 }, ids); + + var best = await service.FindBestAgentAsync(ticket); + Assert.Equal(501, best); + } + finally + { + await db.DisposeAsync(); + } + } + + [Fact] + public async Task SameProficiencySum_PrefersLowerOpenWorkload() + { + var (db, ticket) = await SeedAsync( + "ESC-R3", + new SkillRow[] + { + new(702, 4, 4), + new(703, 4, 4), + }); + + try + { + var now = DateTime.UtcNow; + for (var i = 0; i < 5; i++) + { + db.Tickets.Add(new Ticket + { + Reference = $"BLK-702-{ticket.Reference}-{i}", + Subject = "x", + Status = TicketStatus.Open, + AssignedTo = 702, + CreatedAt = now, + UpdatedAt = now, + }); + } + + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + ticket = await db.Tickets.AsNoTracking().Include(t => t.Tags).SingleAsync(t => t.Reference == ticket.Reference); + + var ids = await new SkillRoutingService(db).FindMatchingAgentIdsAsync(ticket); + Assert.Equal(new[] { 703, 702 }, ids.ToArray()); + } + finally + { + await db.DisposeAsync(); + } + } + + [Fact] + public async Task NoRoutingRules_YieldsEmpty() + { + var db = TestHelpers.CreateInMemoryDb(); + try + { + var now = DateTime.UtcNow; + var tag = new Tag { Name = "only", Slug = "only", CreatedAt = now, UpdatedAt = now }; + var dept = new Department { Name = "onlyd", Slug = "onlyd", IsActive = true, CreatedAt = now, UpdatedAt = now }; + db.Tags.Add(tag); + db.Departments.Add(dept); + db.SaveChanges(); + + var ticket = new Ticket + { + Reference = "ESC-R4", + Subject = "-", + Status = TicketStatus.Open, + Priority = TicketPriority.Medium, + DepartmentId = dept.Id, + CreatedAt = now, + UpdatedAt = now, + }; + + ticket.Tags.Add(tag); + + db.Tickets.Add(ticket); + db.SaveChanges(); + + db.ChangeTracker.Clear(); + ticket = await db.Tickets.AsNoTracking().Include(t => t.Tags).SingleAsync(); + + Assert.Empty(await new SkillRoutingService(db).FindMatchingAgentIdsAsync(ticket)); + Assert.Null(await new SkillRoutingService(db).FindBestAgentAsync(ticket)); + } + finally + { + await db.DisposeAsync(); + } + } +}