804 lines
33 KiB
C#
804 lines
33 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|