shoc-backend/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs
Alexandre Brandizzi 9ef2512e14 fix(vendor-roster): conflict on colliding rename, reject no-op company update (SH-250)
Two review findings on the additive PATCH path:

- AddTechniciansAsync can rename via CompanyFields.Name and write NormalizedName
  against the unique index, but the save had no guard. A colliding rename
  surfaced as an unhandled 500 from the PATCH action instead of a stable client
  conflict. Pre-check the normalized name against other live companies and throw
  VendorRosterDuplicateNameException, with a scoped catch around the save for the
  race where a competing rename commits in between. The controller maps it to a
  409 alongside the existing concurrency conflict.
- Empty-payload validation only rejected a null CompanyFields, so an all-blank
  CompanyFields object was forwarded as a company update, bumping RowVersion and
  rewriting every technician's LastModificationTime without changing any company
  data. Blank fields now collapse to no company change, and a request with
  neither technicians nor a real company value fails validation.
2026-08-18 17:33:16 -03:00

114 lines
4.5 KiB
C#

using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Models
{
public sealed class VendorCompanyRosterReadModel
{
public int CompanyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public byte[]? RowVersion { get; set; }
public List<VendorRosterTechnicianReadModel> Technicians { get; set; } = new();
}
public sealed class VendorRosterTechnicianReadModel
{
public int Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; }
public int TotalJobs { get; set; }
}
public sealed class VendorCompanyRosterWriteModel
{
public int CompanyId { get; set; }
public byte[]? RowVersion { get; set; }
public string Name { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public string? ActorUserId { get; set; }
public List<RosterTechnicianWriteModel> Technicians { get; set; } = new();
}
public sealed class RosterTechnicianWriteModel
{
public int? Id { get; set; }
public string? ContactName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? PreferredContact { get; set; }
public string? TradeSpecialties { get; set; }
public bool IsActive { get; set; } = true;
}
// Additive roster update (SH-250): AddTechnicians rows are inserted and nothing
// is ever removed. CompanyFields carries optional company-level updates where a
// null property means "leave unchanged".
public sealed class VendorCompanyRosterAddWriteModel
{
public int CompanyId { get; set; }
public byte[]? RowVersion { get; set; }
public string? ActorUserId { get; set; }
public VendorRosterCompanyFieldsWriteModel? CompanyFields { get; set; }
public List<RosterTechnicianWriteModel> AddTechnicians { get; set; } = new();
}
public sealed class VendorRosterCompanyFieldsWriteModel
{
public string? Name { get; set; }
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
}
// Raised by the roster data service when a technician removed from the snapshot
// still has open linked work orders, so the whole reconcile must fail. Carries the
// blocking work orders so the API can surface a stable 409 without leaking internals.
/// <summary>
/// Raised when a company rename collides with the unique NormalizedName index.
/// Distinct from <see cref="VendorRosterConflictException"/>, which reports open
/// linked work orders, so callers can return a stable client conflict instead of
/// letting the raw <see cref="Microsoft.EntityFrameworkCore.DbUpdateException"/>
/// surface as a 500.
/// </summary>
public sealed class VendorRosterDuplicateNameException : Exception
{
public VendorRosterDuplicateNameException(string message, Exception inner)
: base(message, inner)
{
}
}
public sealed class VendorRosterConflictException : Exception
{
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }
public VendorRosterConflictException(string message, IReadOnlyList<LinkedWorkOrderInfo> blockedWorkOrders)
: base(message)
{
BlockedWorkOrders = blockedWorkOrders;
}
}
}