mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 15:23:12 +00:00
Seed an organization-wide Area catalogue (East, Central, West, California) with stable ids, add a nullable AreaId to VendorCompany, allow only Admins to change it through the roster endpoints, expose areas facet metadata and an areas[n] company-directory filter with the __unassigned__ sentinel.
543 lines
23 KiB
C#
543 lines
23 KiB
C#
using FluentValidation;
|
|
using FluentValidation.Results;
|
|
using SeaHaven.DataServices.Interfaces;
|
|
using SeaHaven.DataServices.Models;
|
|
using SeaHaven.Services.DTOs;
|
|
using SeaHaven.Services.Helpers;
|
|
using SeaHaven.Services.Interfaces;
|
|
using SeaHaven.Services.Validation;
|
|
using System.Security.Claims;
|
|
|
|
namespace SeaHaven.Services.Implementation
|
|
{
|
|
public class VendorCompanyRosterService : IVendorCompanyRosterService
|
|
{
|
|
private const int MaxNotesLength = 500;
|
|
private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters.";
|
|
private const string AdminRole = "Admin";
|
|
private const string AreaNotFoundMessage = "Area was not found.";
|
|
|
|
private readonly IVendorCompanyRosterDataService _rosterDataService;
|
|
|
|
public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService)
|
|
{
|
|
_rosterDataService = rosterDataService;
|
|
}
|
|
|
|
public async Task<VendorRosterDTO?> GetRosterAsync(
|
|
int? vendorId,
|
|
int? companyId,
|
|
string userId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
|
|
if (!vendorId.HasValue && !companyId.HasValue)
|
|
throw new ValidationException("A vendor id or company id is required.");
|
|
|
|
var roster = await _rosterDataService.GetRosterAsync(vendorId, companyId, cancellationToken);
|
|
return roster == null ? null : MapToDTO(roster);
|
|
}
|
|
|
|
public async Task<VendorRosterDTO> CreateRosterAsync(
|
|
CreateVendorRosterDTO dto,
|
|
string userId,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
|
NormalizeAndValidateCompanyFields(
|
|
dto.Name,
|
|
dto.CompanyPhone,
|
|
dto.Email,
|
|
dto.GoogleMapsUrl,
|
|
dto.Notes,
|
|
nameof(CreateVendorRosterDTO.Notes),
|
|
dto.Technicians);
|
|
|
|
if (dto.Technicians.Any(technician => technician.Id.HasValue))
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.Technicians),
|
|
"Technician ids are not allowed when creating a vendor company.")
|
|
});
|
|
|
|
await EnsureAreaAssignmentAllowedAsync(
|
|
caller,
|
|
dto.AreaId,
|
|
currentAreaId: null,
|
|
nameof(CreateVendorRosterDTO.AreaId),
|
|
cancellationToken);
|
|
|
|
var writeModel = new VendorCompanyRosterWriteModel
|
|
{
|
|
Name = dto.Name,
|
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
|
Email = dto.Email,
|
|
Address = dto.Address,
|
|
City = dto.City,
|
|
State = dto.State,
|
|
Zip = dto.Zip,
|
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
|
Notes = dto.Notes,
|
|
AreaIdProvided = true,
|
|
AreaId = dto.AreaId,
|
|
ActorUserId = userId,
|
|
Technicians = MapTechnicians(dto.Technicians)
|
|
};
|
|
|
|
var saved = await _rosterDataService.CreateRosterAsync(writeModel, cancellationToken);
|
|
return MapToDTO(saved);
|
|
}
|
|
|
|
public async Task<VendorRosterDTO> ReconcileRosterAsync(
|
|
int companyId,
|
|
ReconcileVendorRosterDTO dto,
|
|
string userId,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
dto.Technicians ??= new List<RosterTechnicianInputDTO>();
|
|
NormalizeAndValidateCompanyFields(
|
|
dto.Name,
|
|
dto.CompanyPhone,
|
|
dto.Email,
|
|
dto.GoogleMapsUrl,
|
|
dto.Notes,
|
|
nameof(ReconcileVendorRosterDTO.Notes),
|
|
dto.Technicians);
|
|
|
|
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
|
|
|
|
// Mismatched ids: every non-null technician id must belong to this company.
|
|
await EnsureTechnicianIdsBelongToCompanyAsync(companyId, dto.Technicians, cancellationToken);
|
|
|
|
if (dto.AreaIdProvided)
|
|
await EnsureAreaChangeAllowedAsync(
|
|
companyId,
|
|
caller,
|
|
dto.AreaId,
|
|
nameof(ReconcileVendorRosterDTO.AreaId),
|
|
cancellationToken);
|
|
|
|
var writeModel = new VendorCompanyRosterWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = rowVersion,
|
|
Name = dto.Name,
|
|
CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone),
|
|
Email = dto.Email,
|
|
Address = dto.Address,
|
|
City = dto.City,
|
|
State = dto.State,
|
|
Zip = dto.Zip,
|
|
GoogleMapsUrl = dto.GoogleMapsUrl,
|
|
Notes = dto.Notes,
|
|
AreaIdProvided = dto.AreaIdProvided,
|
|
AreaId = dto.AreaId,
|
|
ActorUserId = userId,
|
|
Technicians = MapTechnicians(dto.Technicians)
|
|
};
|
|
|
|
var saved = await _rosterDataService.SaveRosterAsync(writeModel, cancellationToken);
|
|
return MapToDTO(saved);
|
|
}
|
|
|
|
public async Task<VendorRosterDTO> AddTechniciansAsync(
|
|
int companyId,
|
|
AddTechniciansVendorRosterDTO dto,
|
|
string userId,
|
|
ClaimsPrincipal caller,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
EnsureAuthenticated(userId);
|
|
dto.AddTechnicians ??= new List<RosterTechnicianInputDTO>();
|
|
|
|
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
|
|
|
|
var failures = new List<ValidationFailure>();
|
|
|
|
// Additive contract (SH-250): added technicians are always new rows, so an
|
|
// id from this or any other company is rejected instead of silently
|
|
// mutating an existing technician.
|
|
if (dto.AddTechnicians.Any(technician => technician.Id.HasValue))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
|
|
"Technician ids are not allowed when adding technicians."));
|
|
|
|
var hasCompanyChange = dto.CompanyFields != null && HasAnyCompanyValue(dto.CompanyFields);
|
|
if (dto.AddTechnicians.Count == 0 && !hasCompanyChange)
|
|
failures.Add(new ValidationFailure(
|
|
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
|
|
"At least one technician to add or a company update is required."));
|
|
|
|
NormalizeAndValidateTechnicians(nameof(AddTechniciansVendorRosterDTO.AddTechnicians), dto.AddTechnicians, failures);
|
|
|
|
VendorRosterCompanyFieldsWriteModel? companyFields = null;
|
|
if (dto.CompanyFields != null)
|
|
{
|
|
companyFields = ValidateAndMapCompanyFields(dto.CompanyFields, failures);
|
|
|
|
// SH-250: blank company fields all map to null ("unchanged"), so an empty
|
|
// or all-blank CompanyFields object carries no company update. Forwarding
|
|
// it as a non-null write model made the data layer bump RowVersion and
|
|
// rewrite every technician's LastModificationTime for a no-op request.
|
|
if (HasNoCompanyChange(companyFields))
|
|
companyFields = null;
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
throw new ValidationException(failures);
|
|
|
|
if (companyFields is { AreaIdProvided: true })
|
|
await EnsureAreaChangeAllowedAsync(
|
|
companyId,
|
|
caller,
|
|
companyFields.AreaId,
|
|
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.AreaId)}",
|
|
cancellationToken);
|
|
|
|
// The contact-group invariant ("at least one company phone or email") is
|
|
// preserved by construction: blank company fields map to null ("unchanged"),
|
|
// so this endpoint can only set valid phone/email values, never clear them.
|
|
|
|
var writeModel = new VendorCompanyRosterAddWriteModel
|
|
{
|
|
CompanyId = companyId,
|
|
RowVersion = rowVersion,
|
|
ActorUserId = userId,
|
|
CompanyFields = companyFields,
|
|
AddTechnicians = MapTechnicians(dto.AddTechnicians)
|
|
};
|
|
|
|
var saved = await _rosterDataService.AddTechniciansAsync(writeModel, cancellationToken);
|
|
return MapToDTO(saved);
|
|
}
|
|
|
|
// SH-278: Area assignment is Admin-only. A payload that leaves the stored Area
|
|
// unchanged is allowed for any authenticated caller, so non-admin users can
|
|
// still save other company fields while round-tripping the current Area.
|
|
private async Task EnsureAreaChangeAllowedAsync(
|
|
int companyId,
|
|
ClaimsPrincipal caller,
|
|
int? requestedAreaId,
|
|
string propertyName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken)
|
|
?? throw new KeyNotFoundException($"Vendor company with ID {companyId} not found.");
|
|
|
|
await EnsureAreaAssignmentAllowedAsync(
|
|
caller,
|
|
requestedAreaId,
|
|
roster.AreaId,
|
|
propertyName,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task EnsureAreaAssignmentAllowedAsync(
|
|
ClaimsPrincipal caller,
|
|
int? requestedAreaId,
|
|
int? currentAreaId,
|
|
string propertyName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (requestedAreaId == currentAreaId)
|
|
return;
|
|
|
|
if (caller?.IsInRole(AdminRole) != true)
|
|
throw new VendorAreaAssignmentForbiddenException();
|
|
|
|
if (requestedAreaId is int areaId
|
|
&& (areaId <= 0 || !await _rosterDataService.IsActiveAreaAsync(areaId, cancellationToken)))
|
|
{
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(propertyName, AreaNotFoundMessage)
|
|
});
|
|
}
|
|
}
|
|
|
|
private static void EnsureAuthenticated(string userId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
throw new UnauthorizedAccessException("An authenticated user is required.");
|
|
}
|
|
|
|
private static void NormalizeAndValidateCompanyFields(
|
|
string name,
|
|
string? companyPhone,
|
|
string? email,
|
|
string? googleMapsUrl,
|
|
string? notes,
|
|
string notesPropertyName,
|
|
List<RosterTechnicianInputDTO> technicians)
|
|
{
|
|
var failures = new List<ValidationFailure>();
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Name), "Company name is required."));
|
|
|
|
// Contact group: the company must expose at least one contact channel.
|
|
var normalizedCompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(companyPhone);
|
|
if (string.IsNullOrWhiteSpace(normalizedCompanyPhone) && string.IsNullOrWhiteSpace(email))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
|
"At least one company phone or email is required."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(normalizedCompanyPhone) &&
|
|
!VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedCompanyPhone))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.CompanyPhone),
|
|
"Company phone must be in North American format: (XXX) XXX-XXXX"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(email) && !BeValidEmail(email))
|
|
failures.Add(new ValidationFailure(nameof(CreateVendorRosterDTO.Email), "Invalid email address."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(googleMapsUrl) && !BeValidAbsoluteHttpsUrl(googleMapsUrl))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.GoogleMapsUrl),
|
|
"Google Maps URL must be an absolute HTTPS URL."));
|
|
|
|
ValidateNotes(notes, notesPropertyName, failures);
|
|
|
|
// Technician phones must be valid North American format when provided.
|
|
NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures);
|
|
|
|
// Duplicate technician ids are not allowed.
|
|
var duplicateIds = technicians
|
|
.Where(t => t.Id.HasValue)
|
|
.GroupBy(t => t.Id!.Value)
|
|
.Where(group => group.Count() > 1)
|
|
.Select(group => group.Key)
|
|
.ToList();
|
|
|
|
if (duplicateIds.Count > 0)
|
|
failures.Add(new ValidationFailure(
|
|
nameof(CreateVendorRosterDTO.Technicians),
|
|
"Duplicate technician ids are not allowed."));
|
|
|
|
if (failures.Count > 0)
|
|
throw new ValidationException(failures);
|
|
}
|
|
|
|
// Normalizes technician phones in place and collects format failures using the
|
|
// supplied property-name prefix so each endpoint reports its own payload path.
|
|
private static void NormalizeAndValidateTechnicians(
|
|
string techniciansPropertyName,
|
|
List<RosterTechnicianInputDTO> technicians,
|
|
List<ValidationFailure> failures)
|
|
{
|
|
for (var i = 0; i < technicians.Count; i++)
|
|
{
|
|
var technician = technicians[i];
|
|
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
|
|
technician.Phone = normalized;
|
|
|
|
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
|
|
failures.Add(new ValidationFailure(
|
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
|
|
"Phone must be in North American format: (XXX) XXX-XXXX"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
|
|
failures.Add(new ValidationFailure(
|
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
|
|
"Invalid email address."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
|
|
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
|
|
failures.Add(new ValidationFailure(
|
|
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
|
|
"PreferredContact must be one of: Phone, Email, Text"));
|
|
}
|
|
}
|
|
|
|
// Validates optional company-level updates for the additive path and maps them
|
|
// to the write model. A null (or blank) field means "leave unchanged"; blank
|
|
// values normalize to null so a partial update can never clear data by accident.
|
|
private static bool HasAnyCompanyValue(VendorRosterCompanyFieldsDTO fields) =>
|
|
!string.IsNullOrWhiteSpace(fields.Name)
|
|
|| !string.IsNullOrWhiteSpace(fields.CompanyPhone)
|
|
|| !string.IsNullOrWhiteSpace(fields.Email)
|
|
|| !string.IsNullOrWhiteSpace(fields.Address)
|
|
|| !string.IsNullOrWhiteSpace(fields.City)
|
|
|| !string.IsNullOrWhiteSpace(fields.State)
|
|
|| !string.IsNullOrWhiteSpace(fields.Zip)
|
|
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|
|
|| !string.IsNullOrWhiteSpace(fields.Notes)
|
|
|| fields.AreaIdProvided;
|
|
|
|
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
|
|
model.Name == null
|
|
&& model.CompanyPhone == null
|
|
&& model.Email == null
|
|
&& model.Address == null
|
|
&& model.City == null
|
|
&& model.State == null
|
|
&& model.Zip == null
|
|
&& model.GoogleMapsUrl == null
|
|
&& model.Notes == null
|
|
&& !model.AreaIdProvided;
|
|
|
|
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
|
|
VendorRosterCompanyFieldsDTO fields,
|
|
List<ValidationFailure> failures)
|
|
{
|
|
var name = fields.Name?.Trim();
|
|
if (fields.Name != null && string.IsNullOrWhiteSpace(name))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(VendorRosterCompanyFieldsDTO.Name),
|
|
"Company name is required."));
|
|
|
|
var normalizedPhone = VendorPhoneNormalizer.NormalizeToCanonical(fields.CompanyPhone);
|
|
if (normalizedPhone != null && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedPhone))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(VendorRosterCompanyFieldsDTO.CompanyPhone),
|
|
"Company phone must be in North American format: (XXX) XXX-XXXX"));
|
|
|
|
if (!string.IsNullOrWhiteSpace(fields.Email) && !BeValidEmail(fields.Email))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(VendorRosterCompanyFieldsDTO.Email),
|
|
"Invalid email address."));
|
|
|
|
if (!string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) && !BeValidAbsoluteHttpsUrl(fields.GoogleMapsUrl))
|
|
failures.Add(new ValidationFailure(
|
|
nameof(VendorRosterCompanyFieldsDTO.GoogleMapsUrl),
|
|
"Google Maps URL must be an absolute HTTPS URL."));
|
|
|
|
ValidateNotes(
|
|
fields.Notes,
|
|
$"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.Notes)}",
|
|
failures);
|
|
|
|
return new VendorRosterCompanyFieldsWriteModel
|
|
{
|
|
Name = name,
|
|
CompanyPhone = normalizedPhone,
|
|
Email = string.IsNullOrWhiteSpace(fields.Email) ? null : fields.Email,
|
|
Address = string.IsNullOrWhiteSpace(fields.Address) ? null : fields.Address,
|
|
City = string.IsNullOrWhiteSpace(fields.City) ? null : fields.City,
|
|
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
|
|
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
|
|
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
|
|
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes,
|
|
AreaIdProvided = fields.AreaIdProvided,
|
|
AreaId = fields.AreaId
|
|
};
|
|
}
|
|
|
|
private static void ValidateNotes(
|
|
string? notes,
|
|
string propertyName,
|
|
List<ValidationFailure> failures)
|
|
{
|
|
if (notes?.Length > MaxNotesLength)
|
|
failures.Add(new ValidationFailure(propertyName, NotesMaxLengthMessage));
|
|
}
|
|
|
|
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
|
|
int companyId,
|
|
List<RosterTechnicianInputDTO> technicians,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var requestedIds = technicians
|
|
.Where(t => t.Id.HasValue)
|
|
.Select(t => t.Id!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
if (requestedIds.Count == 0)
|
|
return;
|
|
|
|
var roster = await _rosterDataService.GetRosterAsync(null, companyId, cancellationToken);
|
|
if (roster == null)
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.Name), $"No vendor company exists with ID {companyId}.")
|
|
});
|
|
|
|
var validIds = roster.Technicians.Select(t => t.Id).ToHashSet();
|
|
var mismatched = requestedIds.Where(id => !validIds.Contains(id)).ToList();
|
|
|
|
if (mismatched.Count > 0)
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(
|
|
nameof(ReconcileVendorRosterDTO.Technicians),
|
|
$"Technician ids do not belong to company {companyId}: {string.Join(", ", mismatched)}.")
|
|
});
|
|
}
|
|
|
|
private static List<RosterTechnicianWriteModel> MapTechnicians(List<RosterTechnicianInputDTO> technicians)
|
|
{
|
|
return technicians.Select(t => new RosterTechnicianWriteModel
|
|
{
|
|
Id = t.Id,
|
|
ContactName = t.ContactName,
|
|
Phone = t.Phone,
|
|
Email = t.Email,
|
|
PreferredContact = t.PreferredContact,
|
|
TradeSpecialties = t.TradeSpecialties,
|
|
IsActive = t.IsActive ?? true
|
|
}).ToList();
|
|
}
|
|
|
|
private static byte[] ParseRequiredRowVersion(string? base64)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Row version is required.")
|
|
});
|
|
|
|
try
|
|
{
|
|
return Convert.FromBase64String(base64);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
throw new ValidationException(new[]
|
|
{
|
|
new ValidationFailure(nameof(ReconcileVendorRosterDTO.RowVersion), "Invalid row version format.")
|
|
});
|
|
}
|
|
}
|
|
|
|
private static bool BeValidEmail(string? value) =>
|
|
System.Net.Mail.MailAddress.TryCreate(value, out var address) && address.Address == value;
|
|
|
|
private static bool BeValidAbsoluteHttpsUrl(string? value) =>
|
|
Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps;
|
|
|
|
private static VendorRosterDTO MapToDTO(VendorCompanyRosterReadModel roster) => new()
|
|
{
|
|
CompanyId = roster.CompanyId,
|
|
Name = roster.Name,
|
|
CompanyPhone = roster.CompanyPhone,
|
|
Email = roster.Email,
|
|
Address = roster.Address,
|
|
City = roster.City,
|
|
State = roster.State,
|
|
Zip = roster.Zip,
|
|
GoogleMapsUrl = roster.GoogleMapsUrl,
|
|
Notes = roster.Notes,
|
|
AreaId = roster.AreaId,
|
|
AreaName = roster.AreaName,
|
|
RowVersion = roster.RowVersion == null ? null : Convert.ToBase64String(roster.RowVersion),
|
|
Technicians = roster.Technicians.Select(t => new VendorRosterTechnicianDTO
|
|
{
|
|
Id = t.Id,
|
|
ContactName = t.ContactName,
|
|
Phone = t.Phone,
|
|
Email = t.Email,
|
|
PreferredContact = t.PreferredContact,
|
|
TradeSpecialties = t.TradeSpecialties,
|
|
IsActive = t.IsActive,
|
|
TotalJobs = t.TotalJobs
|
|
}).ToList()
|
|
};
|
|
}
|
|
}
|