Merge pull request #185 from Sea-Haven-Industries/feat/ab/sh-313-completion-templates-api

feat(completion-templates): template content, search, linked work orders and safe delete
This commit is contained in:
Alexandre Brandizzi 2026-09-25 14:49:35 +00:00 • committed by GitHub
commit f9cdbaa06b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 5739 additions and 154 deletions

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 51 actions (Editworkorder binds two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
/// come from 52 actions (Editworkorder binds two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -60,6 +60,7 @@ public class WorkOrderRouteContractTests
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET completion-templates/{id:int}/linked-work-orders",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",

View file

@ -18,83 +18,111 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly ICompletionDocTemplateService _completionDocTemplateService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
ICompletionDocTemplateService completionDocTemplateService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_completionDocTemplateService = completionDocTemplateService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? search = null,
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null)
[FromQuery] WorkOrderType? workOrderType = null,
CancellationToken cancellationToken = default)
{
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id)
public async Task<IActionResult> GetCompletionTemplate(int id, CancellationToken cancellationToken)
{
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
var template = await _completionDocTemplateService.GetAsync(id, cancellationToken);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
public async Task<IActionResult> GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken);
return Ok(linked);
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
}
}
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate(
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
public async Task<IActionResult> UpdateCompletionTemplate(
int id,
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken);
return Ok(updated);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id)
public async Task<IActionResult> DeleteCompletionTemplate(int id, CancellationToken cancellationToken)
{
try
{
await _workOrderCompletionService.DeleteTemplateAsync(id);
await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
catch (WorkOrderBoardValidationException ex)
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
private IActionResult MapTemplateError(WorkOrderBoardValidationException ex)
{
var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message };
return ex.Code switch
{
"Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body),
"NotFound" => NotFound(body),
_ => UnprocessableEntity(body)
};
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(

View file

@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<CompletionDocTemplate>()
.Property(t => t.ExtraSafetyNote)
.HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength);
builder.Entity<CompletionDocTemplateProcedure>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength);
entity.HasOne(p => p.CompletionDocTemplate)
.WithMany(t => t.Procedures)
.HasForeignKey(p => p.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder });
});
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
@ -344,6 +361,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<CompletionDocTemplateProcedure> CompletionDocTemplateProcedures { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class CompletionDocTemplateProcedures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.CreateTable(
name: "CompletionDocTemplateProcedures",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletionDocTemplateProcedures", x => x.Id);
table.ForeignKey(
name: "FK_CompletionDocTemplateProcedures_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CompletionDocTemplateProcedures_CompletionDocTemplateId_SortOrder",
table: "CompletionDocTemplateProcedures",
columns: new[] { "CompletionDocTemplateId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompletionDocTemplateProcedures");
migrationBuilder.DropColumn(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates");
}
}
}

View file

@ -523,6 +523,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("ExtraSafetyNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -560,6 +564,36 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId", "SortOrder");
b.ToTable("CompletionDocTemplateProcedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.Property<int>("Id")
@ -3481,6 +3515,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany("Procedures")
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact")
@ -4108,6 +4153,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Navigation("Procedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
{
b.Navigation("WorkOrderContacts");

View file

@ -4,10 +4,32 @@ namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public const int ExtraSafetyNoteMaxLength = 2000;
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
// At most one template-specific note, printed after the standard
// safety bullets every generated document carries.
public string? ExtraSafetyNote { get; set; }
public virtual ICollection<CompletionDocTemplateProcedure> Procedures { get; set; } = new List<CompletionDocTemplateProcedure>();
}
// Ordered procedure step of a completion document template. SortOrder is the
// position the author gave it; documents print procedures in that order.
public class CompletionDocTemplateProcedure
{
public const int NameMaxLength = 200;
public int Id { get; set; }
public int CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
}
}

View file

@ -1,12 +1,17 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private static readonly LifecycleStatus[] OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToArray();
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
@ -14,33 +19,6 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
@ -70,33 +48,149 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
public async Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(t => t.Name.ToLower().Contains(term));
}
return await ProjectDetail(query).ToListAsync(cancellationToken);
}
public async Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true);
return await ProjectDetail(query).FirstOrDefaultAsync(cancellationToken);
}
public Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates
.Include(t => t.Procedures)
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public async Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
await _context.CompletionDocTemplates.AddAsync(template, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return template.Id;
}
public async Task UpdateAsync(CompletionDocTemplate template)
public async Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken)
{
if (replacementProcedures != null)
{
// Explicit removal: the context forces Restrict on every FK before the
// procedure cascade is configured, so orphan deletion is not relied on.
_context.CompletionDocTemplateProcedures.RemoveRange(template.Procedures.ToList());
foreach (var procedure in replacementProcedures)
{
procedure.CompletionDocTemplateId = template.Id;
_context.CompletionDocTemplateProcedures.Add(procedure);
}
}
template.LastModificationTime = DateTime.UtcNow;
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteAsync(int id)
public async Task<bool> DeleteAndUnlinkServicesAsync(
int id,
string? deleterUserId,
CancellationToken cancellationToken)
{
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
var template = await _context.CompletionDocTemplates
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
if (template == null)
return false;
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
var now = DateTime.UtcNow;
template.IsDeleted = true;
template.DeletionTime = now;
template.DeleterUserId = deleterUserId;
var linkedServices = await _context.Services
.Where(s => s.CompletionDocTemplateId == id)
.ToListAsync(cancellationToken);
foreach (var service in linkedServices)
{
service.CompletionDocTemplateId = null;
service.LastModificationTime = now;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
var linkedServiceIds = _context.Services
.Where(s => s.CompletionDocTemplateId == templateId)
.Select(s => (int?)s.Id);
// Shared status filter so legacy rows without a LifecycleStatus are read
// through their legacy status text (a legacy "completed" row is not open).
workOrders = WorkOrderBoardQueryFilters.ApplyStatusFilter(workOrders, OpenStatuses);
return await workOrders
.Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId))
.OrderBy(w => w.Id)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
private IQueryable<CompletionDocTemplateDetailRow> ProjectDetail(IQueryable<CompletionDocTemplate> templates)
=> from t in templates
join u in _context.Users on t.createdby equals u.Id into creators
from creator in creators.DefaultIfEmpty()
orderby t.Name, t.Id
select new CompletionDocTemplateDetailRow(
t.Id,
t.Name,
t.ServiceKey,
t.WorkOrderType,
t.TemplateUrl,
t.IsActive,
t.ExtraSafetyNote,
t.CreatedDate,
t.LastModificationTime,
creator != null ? creator.FirstName : null,
creator != null ? creator.LastName : null,
t.Procedures
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Select(p => new CompletionDocTemplateProcedureRow(p.Id, p.SortOrder, p.Name, p.Description))
.ToList());
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}

View file

@ -41,9 +41,6 @@ namespace SeaHaven.DataServices.Implementation
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}

View file

@ -0,0 +1,26 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record CompletionDocTemplateProcedureRow(
int Id,
int SortOrder,
string Name,
string Description);
// Read model for the completion document templates registry: the legacy
// columns plus authored content and audit display data.
public record CompletionDocTemplateDetailRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive,
string? ExtraSafetyNote,
DateTime? CreatedAt,
DateTime? UpdatedAt,
string? CreatedByFirstName,
string? CreatedByLastName,
IReadOnlyList<CompletionDocTemplateProcedureRow> Procedures);
}

View file

@ -15,7 +15,6 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -25,11 +25,43 @@ namespace SeaHaven.DataServices.Interfaces
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken);
/// <summary>Tracked, not-deleted template with its procedures loaded, for editing.</summary>
Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken);
Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken);
/// <summary>
/// Commits edits to a template from <see cref="GetTrackedForEditAsync"/>. A non-null
/// <paramref name="replacementProcedures"/> replaces the whole procedure list.
/// </summary>
Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken);
/// <summary>
/// Soft-deletes the template and clears every Service link to it in the same commit.
/// Services keep RequiresCompletionDocument. False when the template does not exist.
/// </summary>
Task<bool> DeleteAndUnlinkServicesAsync(int id, string? deleterUserId, CancellationToken cancellationToken);
/// <summary>
/// Ids of open (non-terminal) work orders whose Service links this template,
/// restricted to <paramref name="accountId"/> when set.
/// </summary>
Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken);
}
}

View file

@ -30,13 +30,66 @@ namespace SeaHaven.Services.DTOs
public bool IsActive { get; set; }
}
// Completion document templates registry item and detail (same shape).
public class CompletionDocTemplateDetailDto : CompletionDocTemplateDto
{
public string? ExtraSafetyNote { get; set; }
public List<CompletionDocTemplateProcedureDto> Procedures { get; set; } = new();
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? CreatedByName { get; set; }
}
public class CompletionDocTemplateProcedureDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int SortOrder { get; set; }
}
// Create/update body. Legacy fields are optional: on update, null leaves the
// stored value unchanged, as does a null Procedures list. WorkOrderType is the
// exception: omitting it keeps the stored value, while an explicit
// "workOrderType": null clears it so the template applies to every work order
// type of its trade. ExtraSafetyNote is always applied, so null removes the note.
public class CompletionDocTemplateCreateDto
{
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
private WorkOrderType? _workOrderType;
public string? Name { get; set; }
public string? ExtraSafetyNote { get; set; }
public List<CompletionDocTemplateProcedureInputDto>? Procedures { get; set; }
public string? ServiceKey { get; set; }
public WorkOrderType? WorkOrderType
{
get => _workOrderType;
set
{
_workOrderType = value;
WorkOrderTypeSpecified = true;
}
}
// True when the body carried workOrderType at all, including as null.
[System.Text.Json.Serialization.JsonIgnore]
public bool WorkOrderTypeSpecified { get; private set; }
public string? TemplateUrl { get; set; }
public bool? IsActive { get; set; }
}
public class CompletionDocTemplateProcedureInputDto
{
public string? Name { get; set; }
public string? Description { get; set; }
}
public class CompletionDocTemplateLinkedWorkOrdersDto
{
public int Count { get; set; }
public List<int> WorkOrderIds { get; set; } = new();
}
public class WorkOrderCompletionDto

View file

@ -0,0 +1,259 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class CompletionDocTemplateService : ICompletionDocTemplateService
{
private readonly ICompletionDocTemplateDataService _templateData;
private readonly ITeamPermissionOverrideDataService _permissionUsers;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly IWorkOrderAccountResolver _accountResolver;
public CompletionDocTemplateService(
ICompletionDocTemplateDataService templateData,
ITeamPermissionOverrideDataService permissionUsers,
ITeamPermissionPolicy permissionPolicy,
IWorkOrderAccountResolver accountResolver)
{
_templateData = templateData;
_permissionUsers = permissionUsers;
_permissionPolicy = permissionPolicy;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var rows = await _templateData.ListAsync(search, serviceKey, workOrderType, cancellationToken);
return rows.Select(Map).ToList();
}
public async Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken);
return row == null ? null : Map(row);
}
public async Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
ClaimsPrincipal user,
int id,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
if (await _templateData.GetDetailAsync(id, cancellationToken) == null)
throw NotFound();
var ids = await _templateData.GetOpenLinkedWorkOrderIdsAsync(id, accountId, cancellationToken);
return new CompletionDocTemplateLinkedWorkOrdersDto { Count = ids.Count, WorkOrderIds = ids.ToList() };
}
public async Task<CompletionDocTemplateDetailDto> CreateAsync(
ClaimsPrincipal user,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.CreateCompletionDocTemplates,
"You do not have permission to create completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = new CompletionDocTemplate
{
Name = input.Name,
ExtraSafetyNote = input.ExtraSafetyNote,
ServiceKey = input.ServiceKey ?? "",
WorkOrderType = request.WorkOrderType,
TemplateUrl = input.TemplateUrl ?? "",
IsActive = request.IsActive ?? true,
createdby = actorId,
Procedures = (input.Procedures ?? new List<CompletionDocTemplateProcedure>()).ToList()
};
var id = await _templateData.CreateAsync(entity, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task<CompletionDocTemplateDetailDto> UpdateAsync(
ClaimsPrincipal user,
int id,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
await EnsureAllowedAsync(
user,
TeamPermissionKeys.EditCompletionDocTemplates,
"You do not have permission to edit completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = await _templateData.GetTrackedForEditAsync(id, cancellationToken) ?? throw NotFound();
entity.Name = input.Name;
entity.ExtraSafetyNote = input.ExtraSafetyNote;
if (input.ServiceKey != null)
entity.ServiceKey = input.ServiceKey;
if (request.WorkOrderTypeSpecified)
entity.WorkOrderType = request.WorkOrderType;
if (input.TemplateUrl != null)
entity.TemplateUrl = input.TemplateUrl;
if (request.IsActive.HasValue)
entity.IsActive = request.IsActive.Value;
await _templateData.SaveEditAsync(entity, input.Procedures, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.DeleteCompletionDocTemplates,
"You do not have permission to delete completion document templates.",
cancellationToken);
if (!await _templateData.DeleteAndUnlinkServicesAsync(id, actorId, cancellationToken))
throw NotFound();
}
private async Task<string> EnsureAllowedAsync(
ClaimsPrincipal user,
string permissionKey,
string deniedMessage,
CancellationToken cancellationToken)
{
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionUsers.GetUserAsync(userId, cancellationToken);
if (permissionUser is null
|| !_permissionPolicy.IsAllowed(permissionUser.RoleName, permissionKey, permissionUser.Overrides))
{
throw new WorkOrderBoardValidationException("Forbidden", deniedMessage);
}
return userId!;
}
private async Task<CompletionDocTemplateDetailDto> GetSavedAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken) ?? throw NotFound();
return Map(row);
}
private sealed record ValidatedInput(
string Name,
string? ExtraSafetyNote,
string? ServiceKey,
string? TemplateUrl,
IReadOnlyList<CompletionDocTemplateProcedure>? Procedures);
private static ValidatedInput Validate(CompletionDocTemplateCreateDto request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Name))
throw new WorkOrderBoardValidationException("TitleRequired", "Document Title is required.");
var extraSafetyNote = string.IsNullOrWhiteSpace(request.ExtraSafetyNote)
? null
: request.ExtraSafetyNote.Trim();
if (extraSafetyNote?.Length > CompletionDocTemplate.ExtraSafetyNoteMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Extra safety note must be {CompletionDocTemplate.ExtraSafetyNoteMaxLength} characters or fewer.");
}
var templateUrl = request.TemplateUrl?.Trim();
if (!string.IsNullOrEmpty(templateUrl)
&& (!Uri.TryCreate(templateUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
// Blank procedure names and descriptions are allowed: authors may keep a
// placeholder step and fill it in later.
List<CompletionDocTemplateProcedure>? procedures = null;
if (request.Procedures != null)
{
procedures = new List<CompletionDocTemplateProcedure>(request.Procedures.Count);
foreach (var procedure in request.Procedures)
{
var name = procedure?.Name?.Trim() ?? "";
if (name.Length > CompletionDocTemplateProcedure.NameMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Procedure name must be {CompletionDocTemplateProcedure.NameMaxLength} characters or fewer.");
}
procedures.Add(new CompletionDocTemplateProcedure
{
SortOrder = procedures.Count,
Name = name,
Description = procedure?.Description ?? ""
});
}
}
return new ValidatedInput(
request.Name.Trim(),
extraSafetyNote,
request.ServiceKey?.Trim(),
templateUrl,
procedures);
}
private static WorkOrderBoardValidationException NotFound()
=> new("NotFound", "Template not found.");
private static CompletionDocTemplateDetailDto Map(CompletionDocTemplateDetailRow row)
{
var createdByName = string.Join(
" ",
new[] { row.CreatedByFirstName, row.CreatedByLastName }
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(part => part!.Trim()));
return new CompletionDocTemplateDetailDto
{
Id = row.Id,
Name = row.Name,
ServiceKey = row.ServiceKey,
WorkOrderType = row.WorkOrderType,
TemplateUrl = row.TemplateUrl,
IsActive = row.IsActive,
ExtraSafetyNote = row.ExtraSafetyNote,
Procedures = row.Procedures
.Select(p => new CompletionDocTemplateProcedureDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
SortOrder = p.SortOrder
})
.ToList(),
CreatedAt = AsUtc(row.CreatedAt),
UpdatedAt = AsUtc(row.UpdatedAt),
CreatedByName = createdByName.Length == 0 ? null : createdByName
};
}
// Audit timestamps are written as DateTime.UtcNow; the store drops the kind.
private static DateTime? AsUtc(DateTime? value)
=> value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null;
}
}

View file

@ -31,18 +31,6 @@ namespace SeaHaven.Services.Implementation
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType);
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
}
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
{
var row = await _templateData.GetByIdAsync(id);
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
@ -125,49 +113,6 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = new CompletionDocTemplate
{
Name = request.Name.Trim(),
ServiceKey = request.ServiceKey.Trim(),
WorkOrderType = request.WorkOrderType,
TemplateUrl = request.TemplateUrl.Trim(),
IsActive = request.IsActive
};
var created = await _templateData.CreateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
}
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None);
if (entity == null)
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
entity.Name = request.Name.Trim();
entity.ServiceKey = request.ServiceKey.Trim();
entity.WorkOrderType = request.WorkOrderType;
entity.TemplateUrl = request.TemplateUrl.Trim();
entity.IsActive = request.IsActive;
await _templateData.UpdateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
}
public async Task DeleteTemplateAsync(int id)
{
if (!await _templateData.DeleteAsync(id))
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
}
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
@ -185,21 +130,6 @@ namespace SeaHaven.Services.Implementation
return workOrder;
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
}
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))

View file

@ -0,0 +1,40 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Completion document templates registry: authoring (title, extra safety note,
/// ordered procedures), search, and safe deletion. Reads are open to every
/// authenticated caller; writes require the matching team permission.
/// </summary>
public interface ICompletionDocTemplateService
{
Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken);
Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
ClaimsPrincipal user,
int id,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto> CreateAsync(
ClaimsPrincipal user,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto> UpdateAsync(
ClaimsPrincipal user,
int id,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken);
Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
}
}

View file

@ -1,13 +1,10 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCompletionService
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
@ -20,8 +17,5 @@ namespace SeaHaven.Services.Interfaces
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);
}
}

View file

@ -0,0 +1,720 @@
using System.Reflection;
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace SeaHavenIndustries.Tests;
// Completion document templates registry: authoring with ordered procedures,
// search, permission-gated writes, tenant-scoped linked work orders, and a
// delete that unlinks Services without dropping their document requirement.
public class CompletionDocTemplateServiceTests
{
private const string AdminId = "admin-1";
private const string SchedulerId = "scheduler-1";
private const string DispatcherId = "dispatcher-1";
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
SeedUser(context, SchedulerId, "Scheduler", "Sam", "Scheduler");
SeedUser(context, DispatcherId, "Dispatcher", "Dee", "Dispatcher");
context.SaveChanges();
return context;
}
private static void SeedUser(ApplicationDbContext context, string userId, string role, string first, string last)
{
context.Users.Add(new ApplicationUser
{
Id = userId,
UserName = userId,
NormalizedUserName = userId.ToUpperInvariant(),
FirstName = first,
LastName = last
});
var roleId = "role-" + role;
if (context.Roles.Local.All(r => r.Id != roleId))
context.Roles.Add(new IdentityRole { Id = roleId, Name = role, NormalizedName = role.ToUpperInvariant() });
context.UserRoles.Add(new IdentityUserRole<string> { UserId = userId, RoleId = roleId });
}
private static CompletionDocTemplateService NewService(ApplicationDbContext context) =>
new(
new CompletionDocTemplateDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
WorkOrderAccountTestHelpers.Resolver(context));
private static ClaimsPrincipal Caller(string userId, string role, int? accountId = null)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Role, role)
};
claims.Add(accountId.HasValue
? new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString())
: new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static ClaimsPrincipal Admin(int? accountId = null) => Caller(AdminId, "Admin", accountId);
private static ClaimsPrincipal Scheduler() => Caller(SchedulerId, "Scheduler");
private static ClaimsPrincipal Dispatcher() => Caller(DispatcherId, "Dispatcher");
private static CompletionDocTemplateCreateDto Body(
string? name = "Backflow Test Report",
string? extraSafetyNote = null,
params (string? Name, string? Description)[] procedures) => new()
{
Name = name,
ExtraSafetyNote = extraSafetyNote,
Procedures = procedures
.Select(p => new CompletionDocTemplateProcedureInputDto { Name = p.Name, Description = p.Description })
.ToList()
};
[Fact]
public async Task Create_PersistsTitleNoteAndProceduresInSubmittedOrder()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(),
Body(" Backflow Test Report ", " Wear gloves ",
("Backflow Procedure", "line 1\nline 2"),
("Shutoff", "Close valve"),
("Cleanup", "")),
CancellationToken.None);
Assert.Equal("Backflow Test Report", created.Name);
Assert.Equal("Wear gloves", created.ExtraSafetyNote);
Assert.Equal(new[] { "Backflow Procedure", "Shutoff", "Cleanup" }, created.Procedures.Select(p => p.Name));
Assert.Equal(new[] { 0, 1, 2 }, created.Procedures.Select(p => p.SortOrder));
Assert.Equal("line 1\nline 2", created.Procedures[0].Description);
Assert.All(created.Procedures, p => Assert.True(p.Id > 0));
Assert.Equal("Jane Doe", created.CreatedByName);
Assert.NotNull(created.CreatedAt);
Assert.Equal(DateTimeKind.Utc, created.CreatedAt!.Value.Kind);
Assert.Null(created.UpdatedAt);
Assert.True(created.IsActive);
Assert.Equal("", created.ServiceKey);
Assert.Equal("", created.TemplateUrl);
var fetched = await service.GetAsync(created.Id, CancellationToken.None);
Assert.Equal(created.Procedures.Select(p => p.Id), fetched!.Procedures.Select(p => p.Id));
}
[Fact]
public async Task Create_WithZeroProceduresAndNoNote_Succeeds()
{
using var context = NewContext();
var created = await NewService(context).CreateAsync(
Admin(), new CompletionDocTemplateCreateDto { Name = "Empty" }, CancellationToken.None);
Assert.Empty(created.Procedures);
Assert.Null(created.ExtraSafetyNote);
}
[Fact]
public async Task Create_AllowsBlankProcedureNameAndDescription()
{
using var context = NewContext();
var created = await NewService(context).CreateAsync(
Admin(), Body("Placeholder", null, (null, null), (" ", "")), CancellationToken.None);
Assert.Equal(2, created.Procedures.Count);
Assert.All(created.Procedures, p => Assert.Equal("", p.Name));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateAndUpdate_WithoutTitle_ThrowTitleRequired(string? name)
{
using var context = NewContext();
var service = NewService(context);
var existing = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
var onCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body(name), CancellationToken.None));
var onUpdate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), existing.Id, Body(name), CancellationToken.None));
Assert.Equal("TitleRequired", onCreate.Code);
Assert.Equal("Document Title is required.", onCreate.Message);
Assert.Equal("TitleRequired", onUpdate.Code);
Assert.Single(context.CompletionDocTemplates);
}
[Fact]
public async Task Create_RejectsOverlongExtraSafetyNoteAndProcedureName()
{
using var context = NewContext();
var service = NewService(context);
var note = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body("T", new string('n', 2001)), CancellationToken.None));
var procedure = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body("T", null, (new string('p', 201), "d")), CancellationToken.None));
Assert.Equal("InvalidValue", note.Code);
Assert.Equal("InvalidValue", procedure.Code);
Assert.Empty(context.CompletionDocTemplates);
}
[Fact]
public async Task Update_ReplacesWholeProcedureListAndStampsUpdatedAt()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(), Body("T", "Note", ("A", "a"), ("B", "b"), ("C", "c")), CancellationToken.None);
var updated = await service.UpdateAsync(
Admin(), created.Id, Body("T2", "Note", ("C", "c2"), ("A", "a2")), CancellationToken.None);
Assert.Equal("T2", updated.Name);
Assert.Equal(new[] { "C", "A" }, updated.Procedures.Select(p => p.Name));
Assert.Equal(new[] { 0, 1 }, updated.Procedures.Select(p => p.SortOrder));
Assert.Equal(2, context.CompletionDocTemplateProcedures.Count());
Assert.NotNull(updated.UpdatedAt);
Assert.Equal("Jane Doe", updated.CreatedByName);
}
[Fact]
public async Task Update_NullNoteClearsIt_EmptyProceduresClearThem_NullProceduresKeepThem()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(Admin(), Body("T", "Note", ("A", "a")), CancellationToken.None);
var kept = await service.UpdateAsync(
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = null }, CancellationToken.None);
Assert.Null(kept.ExtraSafetyNote);
Assert.Equal(new[] { "A" }, kept.Procedures.Select(p => p.Name));
var cleared = await service.UpdateAsync(
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = new() }, CancellationToken.None);
Assert.Empty(cleared.Procedures);
}
[Fact]
public async Task Update_OmittedLegacyFieldsKeepStoredValues()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Id = 7,
Name = "HVAC PM Completion",
ServiceKey = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/hvac.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var updated = await NewService(context).UpdateAsync(
Admin(), 7, Body("HVAC PM Completion v2", "Mask"), CancellationToken.None);
Assert.Equal("HVAC PM", updated.ServiceKey);
Assert.Equal(WorkOrderType.PM, updated.WorkOrderType);
Assert.Equal("https://example.com/hvac.pdf", updated.TemplateUrl);
Assert.True(updated.IsActive);
Assert.Null(updated.CreatedByName);
}
// Bodies are bound by System.Text.Json with the web defaults, as MVC does.
private static CompletionDocTemplateCreateDto JsonBody(string json) =>
System.Text.Json.JsonSerializer.Deserialize<CompletionDocTemplateCreateDto>(
json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
[Fact]
public async Task Update_WorkOrderTypeExplicitNullClears_OmittedKeeps_ValueSets()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Id = 8,
Name = "HVAC PM Completion",
ServiceKey = "HVAC",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "",
IsActive = true
});
await context.SaveChangesAsync();
var service = NewService(context);
var omitted = JsonBody("""{"name":"HVAC Completion"}""");
Assert.False(omitted.WorkOrderTypeSpecified);
var kept = await service.UpdateAsync(Admin(), 8, omitted, CancellationToken.None);
Assert.Equal(WorkOrderType.PM, kept.WorkOrderType);
var cleared = await service.UpdateAsync(
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":null}"""), CancellationToken.None);
Assert.Null(cleared.WorkOrderType);
Assert.Null((await context.CompletionDocTemplates.AsNoTracking().SingleAsync(t => t.Id == 8)).WorkOrderType);
var set = await service.UpdateAsync(
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":6}"""), CancellationToken.None);
Assert.Equal(WorkOrderType.Reactive, set.WorkOrderType);
}
[Fact]
public async Task Update_MissingOrDeletedTemplate_ThrowsNotFound()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 3, Name = "Gone", IsDeleted = true });
await context.SaveChangesAsync();
var service = NewService(context);
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), 999, Body(), CancellationToken.None));
var deleted = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), 3, Body(), CancellationToken.None));
Assert.Equal("NotFound", missing.Code);
Assert.Equal("NotFound", deleted.Code);
}
[Fact]
public async Task List_SearchIsPartialCaseInsensitiveAndOrderedByName()
{
using var context = NewContext();
var service = NewService(context);
foreach (var name in new[] { "Roof Inspection", "Backflow Test Report", "Annual BACKFLOW check", "HVAC PM" })
await service.CreateAsync(Admin(), Body(name), CancellationToken.None);
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Name = "Backflow deleted", IsDeleted = true });
await context.SaveChangesAsync();
var matches = await service.ListAsync("backFLOW", null, null, CancellationToken.None);
var all = await service.ListAsync(null, null, null, CancellationToken.None);
var none = await service.ListAsync("zzz", null, null, CancellationToken.None);
Assert.Equal(new[] { "Annual BACKFLOW check", "Backflow Test Report" }, matches.Select(t => t.Name));
Assert.Equal(
new[] { "Annual BACKFLOW check", "Backflow Test Report", "HVAC PM", "Roof Inspection" },
all.Select(t => t.Name));
Assert.Empty(none);
}
[Fact]
public async Task List_KeepsLegacyServiceKeyAndWorkOrderTypeFilters()
{
using var context = NewContext();
context.CompletionDocTemplates.AddRange(
new CompletionDocTemplate { Name = "HVAC PM", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.PM },
new CompletionDocTemplate { Name = "HVAC Any", ServiceKey = "HVAC" },
new CompletionDocTemplate { Name = "HVAC Reactive", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.Reactive },
new CompletionDocTemplate { Name = "Plumbing", ServiceKey = "Plumbing" });
await context.SaveChangesAsync();
var rows = await NewService(context).ListAsync(null, "HVAC", WorkOrderType.PM, CancellationToken.None);
Assert.Equal(new[] { "HVAC Any", "HVAC PM" }, rows.Select(t => t.Name));
}
[Fact]
public async Task Writes_FollowTeamPermissions()
{
using var context = NewContext();
var service = NewService(context);
var byScheduler = await service.CreateAsync(Scheduler(), Body("Scheduler template"), CancellationToken.None);
await service.UpdateAsync(Scheduler(), byScheduler.Id, Body("Scheduler edit"), CancellationToken.None);
Assert.Equal("Sam Scheduler", byScheduler.CreatedByName);
var schedulerDelete = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteAsync(Scheduler(), byScheduler.Id, CancellationToken.None));
var dispatcherCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Dispatcher(), Body("Nope"), CancellationToken.None));
var dispatcherEdit = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Dispatcher(), byScheduler.Id, Body("Nope"), CancellationToken.None));
var anonymous = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new ClaimsPrincipal(new ClaimsIdentity()), Body("Nope"), CancellationToken.None));
Assert.All(new[] { schedulerDelete, dispatcherCreate, dispatcherEdit, anonymous },
ex => Assert.Equal("Forbidden", ex.Code));
Assert.Equal("Scheduler edit", context.CompletionDocTemplates.Single().Name);
Assert.NotEqual(true, context.CompletionDocTemplates.Single().IsDeleted);
}
[Fact]
public async Task Writes_HonorPerPersonOverrides()
{
using var context = NewContext();
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = SchedulerId,
PermissionKey = "deleteCompletionDocTemplates",
State = UserPermissionState.Allow
});
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = SchedulerId,
PermissionKey = "createCompletionDocTemplates",
State = UserPermissionState.Deny
});
await context.SaveChangesAsync();
var service = NewService(context);
var template = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
var denied = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Scheduler(), Body("Denied"), CancellationToken.None));
await service.DeleteAsync(Scheduler(), template.Id, CancellationToken.None);
Assert.Equal("Forbidden", denied.Code);
Assert.True(context.CompletionDocTemplates.Single().IsDeleted);
}
[Fact]
public async Task LinkedWorkOrders_CountsOnlyOpenWorkOrdersOfLinkedServicesInCallerTenant()
{
using var context = NewContext();
var template = new CompletionDocTemplate { Id = 10, Name = "Backflow" };
var other = new CompletionDocTemplate { Id = 11, Name = "Other" };
context.CompletionDocTemplates.AddRange(template, other);
context.Services.AddRange(
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 2, Name = "Backflow 2", NormalizedName = "BACKFLOW 2", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 3, Name = "Roof", NormalizedName = "ROOF", RequiresCompletionDocument = true, CompletionDocTemplateId = 11 });
context.workOrders.AddRange(
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 102, ServiceId = 2, AccountId = 1, LifecycleStatus = null },
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
new WorkOrder { Id = 105, ServiceId = 3, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 106, ServiceId = null, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 107, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true },
new WorkOrder { Id = 108, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true },
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.InProgress });
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
await context.SaveChangesAsync();
var service = NewService(context);
var tenantOne = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 1), 10, CancellationToken.None);
var tenantTwo = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 2), 10, CancellationToken.None);
var orgWide = await service.GetLinkedWorkOrdersAsync(Admin(), 10, CancellationToken.None);
Assert.Equal(3, tenantOne.Count);
Assert.Equal(new[] { 101, 102, 111 }, tenantOne.WorkOrderIds);
Assert.Equal(new[] { 205 }, tenantTwo.WorkOrderIds);
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide.WorkOrderIds);
Assert.Equal(4, orgWide.Count);
}
// Legacy rows carry no LifecycleStatus; their legacy status text decides whether they are open.
private static WorkOrder[] LegacyStatusWorkOrders(int serviceId, int accountId) => new[]
{
new WorkOrder { Id = 109, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "Completed" },
new WorkOrder { Id = 110, ServiceId = serviceId, AccountId = accountId, Status = " Cancelled " },
new WorkOrder { Id = 111, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "On Hold" }
};
[Fact]
public async Task LinkedWorkOrders_UnknownTemplateOrMissingScope_Throws()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
await context.SaveChangesAsync();
var service = NewService(context);
var noScope = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, DispatcherId), new Claim(ClaimTypes.Role, "Dispatcher") },
authenticationType: "test"));
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetLinkedWorkOrdersAsync(Admin(), 999, CancellationToken.None));
var forbidden = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetLinkedWorkOrdersAsync(noScope, 10, CancellationToken.None));
Assert.Equal("NotFound", missing.Code);
Assert.Equal("Forbidden", forbidden.Code);
}
[Fact]
public async Task Delete_ClearsServiceLinksKeepsDocumentRequirementAndHidesTemplate()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.AddRange(
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 2, Name = "Unrelated", NormalizedName = "UNRELATED", RequiresCompletionDocument = true, CompletionDocTemplateId = null });
await context.SaveChangesAsync();
var service = NewService(context);
await service.DeleteAsync(Admin(), 10, CancellationToken.None);
var linked = context.Services.Single(s => s.Id == 1);
Assert.Null(linked.CompletionDocTemplateId);
Assert.True(linked.RequiresCompletionDocument);
Assert.True(context.Services.Single(s => s.Id == 2).RequiresCompletionDocument);
var template = context.CompletionDocTemplates.Single();
Assert.True(template.IsDeleted);
Assert.Equal(AdminId, template.DeleterUserId);
Assert.Null(await service.GetAsync(10, CancellationToken.None));
Assert.Empty(await service.ListAsync(null, null, null, CancellationToken.None));
var again = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteAsync(Admin(), 10, CancellationToken.None));
Assert.Equal("NotFound", again.Code);
}
[Fact]
public async Task Reads_ForwardCancellation()
{
using var context = NewContext();
var service = NewService(context);
await service.CreateAsync(Admin(), Body(), CancellationToken.None);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.ListAsync(null, null, null, cts.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(Admin(), Body("Cancelled"), cts.Token));
Assert.Single(context.CompletionDocTemplates);
}
// Relational evidence (SQLite enforces foreign keys; the in-memory provider does not).
[Fact]
public async Task Relational_DeleteOfLinkedTemplateSucceedsAndUnlinksService()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
var template = new CompletionDocTemplate { Name = "Backflow" };
context.CompletionDocTemplates.Add(template);
await context.SaveChangesAsync();
context.Services.Add(new Service
{
Name = "Backflow",
NormalizedName = "BACKFLOW",
RequiresCompletionDocument = true,
CompletionDocTemplateId = template.Id
});
await context.SaveChangesAsync();
var service = NewService(context);
await service.DeleteAsync(Admin(), template.Id, CancellationToken.None);
context.ChangeTracker.Clear();
var stored = await context.Services.SingleAsync();
Assert.Null(stored.CompletionDocTemplateId);
Assert.True(stored.RequiresCompletionDocument);
Assert.True((await context.CompletionDocTemplates.SingleAsync()).IsDeleted);
}
[Fact]
public async Task Relational_LinkedWorkOrdersExcludeTerminalAndLegacyTerminalRows()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "Other Corp");
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.Add(new Service
{
Id = 1,
Name = "Backflow",
NormalizedName = "BACKFLOW",
RequiresCompletionDocument = true,
CompletionDocTemplateId = 10
});
context.workOrders.AddRange(
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 102, ServiceId = 1, AccountId = 1 },
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.OnSite });
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
await context.SaveChangesAsync();
var data = new CompletionDocTemplateDataService(context);
var tenantOne = await data.GetOpenLinkedWorkOrderIdsAsync(10, 1, CancellationToken.None);
var orgWide = await data.GetOpenLinkedWorkOrderIdsAsync(10, null, CancellationToken.None);
Assert.Equal(new[] { 101, 102, 111 }, tenantOne);
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide);
}
[Fact]
public async Task Relational_UpdateReplacesProceduresInOrder()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
await context.SaveChangesAsync();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(), Body("T", null, ("A", "a"), ("B", "b")), CancellationToken.None);
context.ChangeTracker.Clear();
var updated = await service.UpdateAsync(
Admin(), created.Id, Body("T", null, ("B", "b"), ("C", "c"), ("A", "a")), CancellationToken.None);
Assert.Equal(new[] { "B", "C", "A" }, updated.Procedures.Select(p => p.Name));
Assert.Equal("Jane Doe", updated.CreatedByName);
context.ChangeTracker.Clear();
Assert.Equal(3, await context.CompletionDocTemplateProcedures.CountAsync());
}
private static async Task<ApplicationDbContext> NewSqliteContextAsync(SqliteConnection connection)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
var context = new SqliteTemplateTestDbContext(options);
await context.Database.EnsureCreatedAsync();
return context;
}
private sealed class SqliteTemplateTestDbContext : ApplicationDbContext
{
public SqliteTemplateTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
// HTTP contract of the template endpoints.
private static WorkOrderCompletionController NewController(ApplicationDbContext context, ClaimsPrincipal user) =>
new(null!, NewService(context), null!)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = user } }
};
[Fact]
public async Task Controller_MapsTitleRequiredTo422AndForbiddenTo403()
{
using var context = NewContext();
var blank = await NewController(context, Admin())
.CreateCompletionTemplate(Body(" "), CancellationToken.None);
var denied = await NewController(context, Dispatcher())
.CreateCompletionTemplate(Body("Allowed title"), CancellationToken.None);
var missing = await NewController(context, Admin())
.DeleteCompletionTemplate(999, CancellationToken.None);
var unprocessable = Assert.IsType<UnprocessableEntityObjectResult>(blank);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(unprocessable.Value);
Assert.Equal("TitleRequired", error.Code);
Assert.Equal("Document Title is required.", error.Message);
var forbidden = Assert.IsType<ObjectResult>(denied);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
Assert.IsType<NotFoundObjectResult>(missing);
}
[Fact]
public async Task Controller_LinkedWorkOrdersAndDelete_ReturnContractShapes()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.Add(new Service { Id = 1, Name = "B", NormalizedName = "B", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 });
context.workOrders.Add(new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var controller = NewController(context, Admin());
var linked = await controller.GetCompletionTemplateLinkedWorkOrders(10, CancellationToken.None);
var deleted = await controller.DeleteCompletionTemplate(10, CancellationToken.None);
var body = Assert.IsType<CompletionDocTemplateLinkedWorkOrdersDto>(Assert.IsType<OkObjectResult>(linked).Value);
Assert.Equal(1, body.Count);
Assert.Equal(new[] { 101 }, body.WorkOrderIds);
Assert.IsType<NoContentResult>(deleted);
}
// Migration shape (G6 evidence): additive only.
[Fact]
public void Migration_Up_AddsBoundedNoteAndCascadingProcedureTableOnly()
{
var operations = BuildOperations("Up");
var column = Assert.Single(operations.OfType<AddColumnOperation>());
Assert.Equal("CompletionDocTemplates", column.Table);
Assert.Equal("ExtraSafetyNote", column.Name);
Assert.True(column.IsNullable);
Assert.Equal(2000, column.MaxLength);
var table = Assert.Single(operations.OfType<CreateTableOperation>());
Assert.Equal("CompletionDocTemplateProcedures", table.Name);
Assert.Contains(table.Columns, c => c.Name == "Name" && c.MaxLength == 200 && !c.IsNullable);
var fk = Assert.Single(table.ForeignKeys);
Assert.Equal("CompletionDocTemplates", fk.PrincipalTable);
Assert.Equal(ReferentialAction.Cascade, fk.OnDelete);
var index = Assert.Single(operations.OfType<CreateIndexOperation>());
Assert.Equal(new[] { "CompletionDocTemplateId", "SortOrder" }, index.Columns);
Assert.Equal(3, operations.Count);
}
[Fact]
public void Migration_Down_DropsOnlyWhatUpAdded()
{
var operations = BuildOperations("Down");
Assert.Equal("CompletionDocTemplateProcedures", Assert.Single(operations.OfType<DropTableOperation>()).Name);
var dropped = Assert.Single(operations.OfType<DropColumnOperation>());
Assert.Equal(("CompletionDocTemplates", "ExtraSafetyNote"), (dropped.Table, dropped.Name));
Assert.Equal(2, operations.Count);
}
private static List<MigrationOperation> BuildOperations(string direction)
{
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new CompletionDocTemplateProcedures(), [builder]);
return builder.Operations;
}
}