first commit
This commit is contained in:
@@ -0,0 +1,803 @@
|
||||
using Fitness_core_BE.DTOs;
|
||||
using Fitness_core_BE.DTOs.AccessDevice;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Helpers;
|
||||
using Fitness_core_BE.Infra.UoW;
|
||||
using Fitness_core_BE.Interfaces;
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns the cloud-side desired state for physical access. User identity is scoped by
|
||||
/// client in AccessProfile; terminal-specific state lives in AccessDeviceBinding.
|
||||
/// Fingerprint template bytes never leave the terminal.
|
||||
/// </summary>
|
||||
public class AccessDeviceService : IAccessDeviceService
|
||||
{
|
||||
private readonly IUnitOfWork _UoW;
|
||||
private readonly IAccessDeviceRepo _accessDeviceRepo;
|
||||
private readonly IAttendanceRepository _attendanceRepo;
|
||||
private readonly IDeviceCommandRepo _deviceCommandRepo;
|
||||
private readonly IPairingCodeRepo _pairingCodeRepo;
|
||||
|
||||
public AccessDeviceService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAccessDeviceRepo accessDeviceRepo,
|
||||
IAttendanceRepository attendanceRepo,
|
||||
IDeviceCommandRepo deviceCommandRepo,
|
||||
IPairingCodeRepo pairingCodeRepo)
|
||||
{
|
||||
_UoW = unitOfWork;
|
||||
_accessDeviceRepo = accessDeviceRepo;
|
||||
_attendanceRepo = attendanceRepo;
|
||||
_deviceCommandRepo = deviceCommandRepo;
|
||||
_pairingCodeRepo = pairingCodeRepo;
|
||||
}
|
||||
|
||||
public async Task<object> AddDevice(object payload)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
try
|
||||
{
|
||||
var device = new AccessDevice
|
||||
{
|
||||
DeviceId = Guid.NewGuid(),
|
||||
DeviceName = DataExtractor.GetRequiredString(data, "DeviceName"),
|
||||
FKClientId = DataExtractor.GetRequiredGuid(data, "FKClientId")
|
||||
};
|
||||
|
||||
await _accessDeviceRepo.AddDeviceAsync(device);
|
||||
await _UoW.CommitAsync();
|
||||
return new { Message = "Device added", device.DeviceId };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> RemoveDevice(object payload)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
try
|
||||
{
|
||||
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
|
||||
var result = await _accessDeviceRepo.RemoveDeviceAsync(clientId);
|
||||
await _UoW.CommitAsync();
|
||||
return new { Message = $"Device {result.DeviceId} disabled", result.DeviceId };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> GeneratePairingCode(object payload)
|
||||
{
|
||||
var data = DeserializePayload.Deserialize(payload);
|
||||
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
|
||||
var pairingCode = new PairingCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = GenerateShortCode(),
|
||||
FKClientId = clientId,
|
||||
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
|
||||
IsUsed = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _pairingCodeRepo.AddAsync(pairingCode);
|
||||
await _UoW.CommitAsync();
|
||||
return new { pairingCode.Code, pairingCode.ExpiresAt };
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility entry point for old admin callers. New flows create an
|
||||
/// AccessProfile automatically when the customer/staff record is created.
|
||||
/// </summary>
|
||||
public async Task<object> AddUser(CustomerAccess requested)
|
||||
{
|
||||
var device = await _accessDeviceRepo.GetDeviceByDId(requested.FKDeviceId);
|
||||
var customer = await _accessDeviceRepo
|
||||
.GetCustomerByIdAsync(requested.FKCustomerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await EnsureAccessProfileAsync(
|
||||
device.FKClientId, customer.FKUserId, "Customer",
|
||||
requested.Name ?? customer.CusName ?? string.Empty, queueProvisioning: false);
|
||||
var binding = await EnsureBindingAsync(profile, device);
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
await _UoW.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
accessNumber = binding.DeviceEmployeeNo,
|
||||
Message = "Access user queued for the on-prem agent."
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> EnsureAccessProfileAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
string personType,
|
||||
string displayName,
|
||||
bool queueProvisioning = true)
|
||||
{
|
||||
var profile = await _accessDeviceRepo.GetAccessProfileAsync(clientId, userId);
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
profile = new AccessProfile
|
||||
{
|
||||
FKClientId = clientId,
|
||||
FKUserId = userId,
|
||||
PersonType = personType,
|
||||
DisplayName = displayName,
|
||||
DesiredAccessState = "Enabled",
|
||||
DesiredVersion = 1,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddAccessProfileAsync(profile);
|
||||
// AccessProfileId is the stable numeric terminal employee number.
|
||||
await _UoW.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.PersonType = personType;
|
||||
profile.DisplayName = displayName;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
if (queueProvisioning)
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
public async Task ProvisionProfilesToDeviceAsync(AccessDevice device)
|
||||
{
|
||||
var profiles = await _accessDeviceRepo
|
||||
.GetAccessProfilesByClientAsync(device.FKClientId);
|
||||
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
var binding = await EnsureBindingAsync(profile, device);
|
||||
if (binding.ProvisionStatus != "Synced")
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason)
|
||||
{
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessProfileAsync(clientId, userId)
|
||||
?? throw new NotFoundException("No access profile exists for this user at this gym.");
|
||||
|
||||
profile.DesiredAccessState = enabled ? "Enabled" : "Disabled";
|
||||
profile.DesiredVersion++;
|
||||
profile.LockReason = enabled ? null : reason;
|
||||
profile.LockedAt = enabled ? null : DateTime.UtcNow;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Include terminals added after the person was originally onboarded.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
foreach (var binding in profile.DeviceBindings)
|
||||
{
|
||||
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
|
||||
continue;
|
||||
binding.ProvisionStatus = "Pending";
|
||||
binding.LastError = null;
|
||||
binding.UpdatedAt = DateTime.UtcNow;
|
||||
await SupersedePendingStateCommandsAsync(binding.AccessDeviceBindingId);
|
||||
await QueueProfileCommandAsync(profile, binding, "SetAccessState");
|
||||
}
|
||||
|
||||
await _UoW.CommitAsync();
|
||||
return (await GetAccessStatusAsync(clientId, userId))!;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetCustomerAccessAsync(
|
||||
Guid clientId, Guid customerId, bool enabled, string? reason)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
var status = await SetUserAccessAsync(clientId, customer.FKUserId, enabled, reason);
|
||||
|
||||
// A couple/group plan is one shared entitlement, so the whole party follows the
|
||||
// member that was acted on -- locking only the primary would leave the partner
|
||||
// badging in on a plan that is locked, and unlocking only the primary would leave
|
||||
// the partner stranded after the reason for the lock is gone.
|
||||
var coMemberIds = await _accessDeviceRepo
|
||||
.GetCoMembersOnSharedSubscriptionsAsync(clientId, customerId);
|
||||
|
||||
foreach (var coMemberId in coMemberIds)
|
||||
{
|
||||
var coMember = await _accessDeviceRepo.GetCustomerByIdAsync(coMemberId);
|
||||
if (coMember == null)
|
||||
continue;
|
||||
|
||||
// Best-effort: a co-member without an access profile yet (never onboarded to
|
||||
// a device) must not fail the lock that was actually requested.
|
||||
try
|
||||
{
|
||||
await SetUserAccessAsync(clientId, coMember.FKUserId, enabled, reason);
|
||||
}
|
||||
catch (NotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<object> SetCustomerFacePhotoAsync(
|
||||
Guid clientId, Guid customerId, string base64Jpeg)
|
||||
{
|
||||
// Validate before opening a transaction: a rejected photo should cost nothing.
|
||||
var photo = FacePhotoValidator.ValidateBase64(base64Jpeg);
|
||||
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessProfileAsync(clientId, customer.FKUserId)
|
||||
?? throw new NotFoundException(
|
||||
"No access profile exists for this member at this gym.");
|
||||
|
||||
profile.FacePhotoStatus = "Pending";
|
||||
profile.FacePhotoError = null;
|
||||
profile.FacePhotoUpdatedAt = DateTime.UtcNow;
|
||||
profile.DesiredVersion++;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Include terminals added after the member was originally onboarded.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
|
||||
var queued = 0;
|
||||
var payload = Convert.ToBase64String(photo.Bytes);
|
||||
foreach (var binding in profile.DeviceBindings)
|
||||
{
|
||||
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
|
||||
continue;
|
||||
await SupersedePendingFacePhotoCommandsAsync(binding.AccessDeviceBindingId);
|
||||
await QueueFacePhotoCommandAsync(profile, binding, payload);
|
||||
queued++;
|
||||
}
|
||||
|
||||
if (queued == 0)
|
||||
{
|
||||
// Nothing can carry the photo to a terminal, so don't leave the member
|
||||
// sitting on "Pending" forever with no command in flight.
|
||||
profile.FacePhotoStatus = "Failed";
|
||||
profile.FacePhotoError =
|
||||
"No reachable access terminal is currently paired with this gym.";
|
||||
}
|
||||
|
||||
await _UoW.CommitAsync();
|
||||
|
||||
return new
|
||||
{
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
facePhotoStatus = profile.FacePhotoStatus,
|
||||
queuedToDevices = queued,
|
||||
width = photo.Width,
|
||||
height = photo.Height,
|
||||
sizeBytes = photo.Bytes.Length
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept separate from QueueProfileCommandAsync: the face payload is large and is
|
||||
// deliberately purged after the terminal confirms, which the person/state commands
|
||||
// must never be.
|
||||
private async Task QueueFacePhotoCommandAsync(
|
||||
AccessProfile profile, AccessDeviceBinding binding, string base64Jpeg)
|
||||
{
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
bindingId = binding.AccessDeviceBindingId,
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
employeeNo = binding.DeviceEmployeeNo,
|
||||
name = profile.DisplayName,
|
||||
faceJpegBase64 = base64Jpeg,
|
||||
desiredVersion = profile.DesiredVersion
|
||||
});
|
||||
|
||||
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = profile.FKClientId,
|
||||
FKDeviceId = binding.FKDeviceId,
|
||||
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
||||
CommandType = "UpsertFacePhoto",
|
||||
PayloadJson = payloadJson,
|
||||
DesiredVersion = profile.DesiredVersion,
|
||||
Status = "Pending",
|
||||
MaxAttempts = 3,
|
||||
NextAttemptAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
// A newer photo replaces an older one that has not reached the device yet, so the
|
||||
// stale payload is dropped rather than left queued (and holding an image).
|
||||
private async Task SupersedePendingFacePhotoCommandsAsync(Guid bindingId)
|
||||
{
|
||||
var stale = await _deviceCommandRepo
|
||||
.GetPendingFacePhotoCommandsAsync(bindingId);
|
||||
foreach (var command in stale)
|
||||
{
|
||||
command.Status = "Superseded";
|
||||
command.PayloadJson = string.Empty;
|
||||
command.ProcessedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<object> SetStaffAccessAsync(
|
||||
Guid clientId, Guid staffId, bool enabled, string? reason)
|
||||
{
|
||||
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
|
||||
?? throw new NotFoundException("Staff member not found in this gym.");
|
||||
return await SetUserAccessAsync(clientId, staff.FKUserId, enabled, reason);
|
||||
}
|
||||
|
||||
public async Task<bool> WhiteListMember(Guid customerId)
|
||||
{
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> BlackListMember(Guid customerId)
|
||||
{
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: false, reason: "Manual");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> LockCustomer(Guid customerId, string reason)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return false;
|
||||
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: false, reason);
|
||||
|
||||
customer.IsLocked = true;
|
||||
customer.LockReason = reason;
|
||||
customer.LockedAt = DateTime.UtcNow;
|
||||
await _UoW.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UnlockCustomer(Guid customerId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
|
||||
if (customer == null)
|
||||
return false;
|
||||
|
||||
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
|
||||
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
|
||||
|
||||
customer.IsLocked = false;
|
||||
customer.LockReason = null;
|
||||
customer.LockedAt = null;
|
||||
await _UoW.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<Customer> UpdateDeviceProfile(Customer customer) =>
|
||||
throw new NotImplementedException();
|
||||
|
||||
public async Task<int> SaveAttendanceRecords(Guid clientId, List<AttendanceDTO> attendanceList)
|
||||
{
|
||||
if (attendanceList.Count == 0)
|
||||
return 0;
|
||||
|
||||
// The source key is stable across JSON DateTime kind conversion and
|
||||
// database round-trips. It also gives the database a final idempotency
|
||||
// guard when an agent retries after an ambiguous HTTP response.
|
||||
var keyedEvents = attendanceList
|
||||
.OrderBy(a => a.EventTime)
|
||||
.Select(dto => new
|
||||
{
|
||||
Dto = dto,
|
||||
SourceEventKey = BuildSourceEventKey(
|
||||
clientId, dto.DeviceId, dto.DeviceEmployeeNo, dto.EventTime, dto.EventType)
|
||||
})
|
||||
.ToList();
|
||||
var candidateKeys = keyedEvents
|
||||
.Select(e => e.SourceEventKey)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var existingKeys = await _attendanceRepo.GetSourceEventKeysAsync(candidateKeys);
|
||||
|
||||
var newRecords = new List<Attendance>();
|
||||
var seenKeys = new HashSet<string>(existingKeys);
|
||||
foreach (var keyedEvent in keyedEvents)
|
||||
{
|
||||
if (!seenKeys.Add(keyedEvent.SourceEventKey))
|
||||
continue;
|
||||
|
||||
var dto = keyedEvent.Dto;
|
||||
newRecords.Add(new Attendance
|
||||
{
|
||||
AttendanceId = Guid.NewGuid(),
|
||||
FKClientId = clientId,
|
||||
FKDeviceId = dto.DeviceId,
|
||||
FKCustomerId = dto.CustomerId,
|
||||
FKUserId = dto.UserId,
|
||||
FKAccessProfileId = dto.AccessProfileId,
|
||||
DeviceUserId = dto.DeviceUserId,
|
||||
DeviceEmployeeNo = dto.DeviceEmployeeNo,
|
||||
SourceEventKey = keyedEvent.SourceEventKey,
|
||||
EventTime = dto.EventTime,
|
||||
EventType = dto.EventType
|
||||
});
|
||||
}
|
||||
|
||||
if (newRecords.Count == 0)
|
||||
return 0;
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
await _attendanceRepo.AddAttendancesAsync(newRecords);
|
||||
await _UoW.CommitAsync();
|
||||
return newRecords.Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
// The agent advances its per-device watermark only after an HTTP success.
|
||||
// Surface persistence failures so it retries instead of losing events.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<UnlinkedDeviceEnrollment>> GetUnlinkedFingerprintsAsync(Guid clientId) =>
|
||||
_accessDeviceRepo.GetUnlinkedEnrollmentsByClientAsync(clientId);
|
||||
|
||||
public async Task<AccessProfile> LinkFingerprintAsync(
|
||||
Guid clientId, Guid customerId, Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
var belongsToGym = await _accessDeviceRepo
|
||||
.IsActiveCustomerMemberAsync(clientId, customerId);
|
||||
if (!belongsToGym)
|
||||
throw new ForbiddenException("This customer does not belong to your gym.");
|
||||
|
||||
return await LinkDevicePersonAsync(
|
||||
clientId, customer.FKUserId, "Customer",
|
||||
customer.CusName ?? string.Empty, unlinkedEnrollmentId);
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> LinkDevicePersonAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
string personType,
|
||||
string displayName,
|
||||
Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var enrollment = await _accessDeviceRepo
|
||||
.GetUnlinkedEnrollmentByIdAsync(unlinkedEnrollmentId)
|
||||
?? throw new NotFoundException("Unlinked device person not found.");
|
||||
|
||||
if (enrollment.LinkedAt != null)
|
||||
throw new BadRequestException("This device person has already been linked.");
|
||||
if (enrollment.AccessDevice.FKClientId != clientId)
|
||||
throw new ForbiddenException("This device person does not belong to your gym.");
|
||||
|
||||
var employeeNo = NormalizeEmployeeNo(enrollment);
|
||||
var occupied = await _accessDeviceRepo.IsDeviceEmployeeNoOccupiedAsync(
|
||||
enrollment.FKDeviceId, employeeNo, userId);
|
||||
if (occupied)
|
||||
throw new BadRequestException($"Device user {employeeNo} is already linked to another person.");
|
||||
|
||||
await _UoW.BeginAsync();
|
||||
try
|
||||
{
|
||||
var profile = await EnsureAccessProfileAsync(
|
||||
clientId, userId, personType, displayName, queueProvisioning: false);
|
||||
var binding = await _accessDeviceRepo.GetBindingAsync(
|
||||
profile.AccessProfileId, enrollment.FKDeviceId);
|
||||
|
||||
if (binding == null)
|
||||
{
|
||||
binding = new AccessDeviceBinding
|
||||
{
|
||||
AccessDeviceBindingId = Guid.NewGuid(),
|
||||
FKAccessProfileId = profile.AccessProfileId,
|
||||
FKDeviceId = enrollment.FKDeviceId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddBindingAsync(binding);
|
||||
profile.DeviceBindings.Add(binding);
|
||||
}
|
||||
|
||||
binding.DeviceEmployeeNo = employeeNo;
|
||||
binding.DeviceReportedName = enrollment.DeviceReportedName;
|
||||
binding.FingerprintCount = enrollment.FingerprintCount;
|
||||
binding.ProvisionStatus = "Synced";
|
||||
binding.ActualAccessState = IsBlocked(enrollment.DeviceUserType) ? "Disabled" : "Enabled";
|
||||
binding.LastSyncedAt = DateTime.UtcNow;
|
||||
binding.LastError = null;
|
||||
binding.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var linkedAt = DateTime.UtcNow;
|
||||
var duplicateDiscoveries =
|
||||
await _accessDeviceRepo.GetDuplicateUnlinkedEnrollmentsAsync(
|
||||
enrollment.FKDeviceId,
|
||||
employeeNo,
|
||||
enrollment.DeviceUserId);
|
||||
foreach (var discovered in duplicateDiscoveries)
|
||||
{
|
||||
discovered.LinkedAt = linkedAt;
|
||||
discovered.FKAccessProfileId = profile.AccessProfileId;
|
||||
}
|
||||
|
||||
// Reconcile all other active terminals for the same gym.
|
||||
await QueueProvisioningForActiveDevicesAsync(profile);
|
||||
await _UoW.CommitAsync();
|
||||
return profile;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _UoW.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AccessProfile> LinkStaffFingerprintAsync(
|
||||
Guid clientId, Guid staffId, Guid unlinkedEnrollmentId)
|
||||
{
|
||||
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
|
||||
?? throw new NotFoundException("Staff member not found in this gym.");
|
||||
return await LinkDevicePersonAsync(
|
||||
clientId, staff.FKUserId, "Staff",
|
||||
staff.User?.UserName ?? string.Empty, unlinkedEnrollmentId);
|
||||
}
|
||||
|
||||
public async Task<AccessStatusDto?> GetAccessStatusAsync(Guid clientId, Guid userId)
|
||||
{
|
||||
var profile = await _accessDeviceRepo
|
||||
.GetAccessStatusProfileAsync(clientId, userId);
|
||||
if (profile == null)
|
||||
return null;
|
||||
|
||||
var bindings = profile.DeviceBindings
|
||||
.Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null)
|
||||
.ToList();
|
||||
var provisionStatus = bindings.Count == 0
|
||||
? "NoDevice"
|
||||
: bindings.Any(b => b.ProvisionStatus == "Failed")
|
||||
? "Failed"
|
||||
: bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending";
|
||||
var desired = profile.DesiredAccessState;
|
||||
var actual = bindings.Count == 0
|
||||
? "Unknown"
|
||||
: bindings.All(b => b.ActualAccessState == desired) ? desired
|
||||
: bindings.All(b => b.ActualAccessState == "Unknown") ? "Unknown" : "Mixed";
|
||||
|
||||
return new AccessStatusDto
|
||||
{
|
||||
AccessProfileId = profile.AccessProfileId,
|
||||
AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo
|
||||
?? $"HF{profile.AccessProfileId}",
|
||||
PersonType = profile.PersonType,
|
||||
DesiredAccessState = desired,
|
||||
ProvisionStatus = provisionStatus,
|
||||
ActualAccessState = actual,
|
||||
FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount),
|
||||
LastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError,
|
||||
LastSyncedAt = bindings.Max(b => b.LastSyncedAt),
|
||||
DeviceCount = bindings.Count,
|
||||
SyncedDeviceCount = bindings.Count(b => b.ProvisionStatus == "Synced")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task QueueProvisioningForActiveDevicesAsync(AccessProfile profile)
|
||||
{
|
||||
var devices = (await _accessDeviceRepo
|
||||
.GetDevicesByClientAsync(profile.FKClientId))
|
||||
.Where(device => device.FKAgentId != null)
|
||||
.ToList();
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
|
||||
?? await EnsureBindingAsync(profile, device);
|
||||
if (binding.ProvisionStatus != "Synced")
|
||||
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AccessDeviceBinding> EnsureBindingAsync(AccessProfile profile, AccessDevice device)
|
||||
{
|
||||
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
|
||||
?? await _accessDeviceRepo.GetBindingAsync(
|
||||
profile.AccessProfileId, device.DeviceId);
|
||||
if (binding != null)
|
||||
return binding;
|
||||
|
||||
binding = new AccessDeviceBinding
|
||||
{
|
||||
AccessDeviceBindingId = Guid.NewGuid(),
|
||||
FKAccessProfileId = profile.AccessProfileId,
|
||||
FKDeviceId = device.DeviceId,
|
||||
// Namespace platform-managed terminal users away from locally-created
|
||||
// numeric ids, which prevents overwriting an unlinked person by collision.
|
||||
DeviceEmployeeNo = $"HF{profile.AccessProfileId}",
|
||||
ProvisionStatus = "Pending",
|
||||
ActualAccessState = "Unknown",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _accessDeviceRepo.AddBindingAsync(binding);
|
||||
profile.DeviceBindings.Add(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private async Task QueueProfileCommandAsync(
|
||||
AccessProfile profile, AccessDeviceBinding binding, string commandType)
|
||||
{
|
||||
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
|
||||
binding.AccessDeviceBindingId,
|
||||
commandType,
|
||||
profile.DesiredVersion);
|
||||
if (alreadyQueued)
|
||||
return;
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
bindingId = binding.AccessDeviceBindingId,
|
||||
accessProfileId = profile.AccessProfileId,
|
||||
employeeNo = binding.DeviceEmployeeNo,
|
||||
name = profile.DisplayName,
|
||||
enabled = profile.DesiredAccessState == "Enabled",
|
||||
desiredVersion = profile.DesiredVersion
|
||||
});
|
||||
|
||||
await _deviceCommandRepo.AddAsync(new DeviceCommand
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FKClientId = profile.FKClientId,
|
||||
FKDeviceId = binding.FKDeviceId,
|
||||
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
|
||||
CommandType = commandType,
|
||||
PayloadJson = payloadJson,
|
||||
DesiredVersion = profile.DesiredVersion,
|
||||
Status = "Pending",
|
||||
MaxAttempts = 5,
|
||||
NextAttemptAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SupersedePendingStateCommandsAsync(Guid bindingId)
|
||||
{
|
||||
var stale = await _deviceCommandRepo
|
||||
.GetPendingStateCommandsAsync(bindingId);
|
||||
foreach (var command in stale)
|
||||
{
|
||||
command.Status = "Superseded";
|
||||
command.ProcessedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<bool> IsDeviceCommandableAsync(Guid deviceId) =>
|
||||
_accessDeviceRepo.IsDeviceCommandableAsync(deviceId);
|
||||
|
||||
private async Task<(Guid clientId, Guid userId)> ResolveCustomerAccessOwnerAsync(Guid customerId)
|
||||
{
|
||||
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
|
||||
?? throw new NotFoundException("Customer not found.");
|
||||
|
||||
var clientId = await _accessDeviceRepo
|
||||
.GetLatestActiveClientIdForCustomerAsync(customerId);
|
||||
if (!clientId.HasValue)
|
||||
throw new NotFoundException("Customer has no active gym membership.");
|
||||
return (clientId.Value, customer.FKUserId);
|
||||
}
|
||||
|
||||
private static string NormalizeEmployeeNo(UnlinkedDeviceEnrollment enrollment) =>
|
||||
!string.IsNullOrWhiteSpace(enrollment.DeviceEmployeeNo)
|
||||
? enrollment.DeviceEmployeeNo
|
||||
: enrollment.DeviceUserId.ToString();
|
||||
|
||||
private static bool IsBlocked(string? userType) =>
|
||||
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string BuildSourceEventKey(
|
||||
Guid clientId,
|
||||
Guid deviceId,
|
||||
string deviceEmployeeNo,
|
||||
DateTime eventTime,
|
||||
string eventType)
|
||||
{
|
||||
// MySQL DATETIME does not preserve DateTime.Kind. Formatting only the
|
||||
// stored wall-clock fields keeps this key identical after reading it back.
|
||||
var normalizedTime = eventTime.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.ffffff", CultureInfo.InvariantCulture);
|
||||
var source = string.Join(
|
||||
"|",
|
||||
clientId.ToString("D"),
|
||||
deviceId.ToString("D"),
|
||||
deviceEmployeeNo.Trim(),
|
||||
normalizedTime,
|
||||
eventType.Trim());
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GenerateShortCode(int length = 8)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var bytes = RandomNumberGenerator.GetBytes(length);
|
||||
var chars = new char[length];
|
||||
for (var i = 0; i < length; i++)
|
||||
chars[i] = alphabet[bytes[i] % alphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user