Files
Core_Be/Services/AccessDeviceService.cs
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

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);
}
}
}