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();
+ }
+ }
+}