using System.Security.Cryptography; using Azaion.Common; using Azaion.Common.Database; using Azaion.Common.Entities; using Azaion.Common.Extensions; using Azaion.Common.Requests; using LinqToDB; using Npgsql; namespace Azaion.Services; public interface IUserService { Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default); Task RegisterDevice(CancellationToken ct = default); Task ValidateUser(LoginRequest request, CancellationToken ct = default); Task GetByEmail(string? email, CancellationToken ct = default); Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default); Task> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct = default); Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default); Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default); Task RemoveUser(string email, CancellationToken ct = default); } public class UserService(IDbFactory dbFactory, ICache cache) : IUserService { private const string DeviceEmailPrefix = "azj-"; private const string DeviceEmailDomain = "@azaion.com"; private const int SerialNumberStart = 4; // index of NNNN inside "azj-NNNN..." (length of DeviceEmailPrefix) private const int SerialNumberLength = 4; private const int DevicePasswordBytes = 16; // hex-encoded → 32 chars public async Task RegisterUser(RegisterUserRequest request, CancellationToken ct = default) { try { await dbFactory.RunAdmin(async db => { await db.InsertAsync(new User { Id = Guid.NewGuid(), Email = request.Email, PasswordHash = request.Password.ToHash(), Role = request.Role, CreatedAt = DateTime.UtcNow, IsEnabled = true }, token: ct); }); } catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation) { throw new BusinessException(ExceptionEnum.EmailExists); } } public async Task RegisterDevice(CancellationToken ct = default) { var (serial, email) = await NextDeviceIdentity(ct); var password = Convert.ToHexString(RandomNumberGenerator.GetBytes(DevicePasswordBytes)).ToLowerInvariant(); await RegisterUser(new RegisterUserRequest { Email = email, Password = password, Role = RoleEnum.CompanionPC }, ct); return new RegisterDeviceResponse { Serial = serial, Email = email, Password = password }; } private async Task<(string Serial, string Email)> NextDeviceIdentity(CancellationToken ct) => await dbFactory.Run(async db => { var lastEmail = await db.Users .Where(u => u.Role == RoleEnum.CompanionPC) .OrderByDescending(u => u.CreatedAt) .Select(u => u.Email) .FirstOrDefaultAsync(token: ct); var nextNumber = 0; if (!string.IsNullOrEmpty(lastEmail) && lastEmail.Length >= SerialNumberStart + SerialNumberLength) { var serialPart = lastEmail.Substring(SerialNumberStart, SerialNumberLength); if (int.TryParse(serialPart, out var current)) nextNumber = current + 1; } var serial = $"{DeviceEmailPrefix}{nextNumber.ToString($"D{SerialNumberLength}")}"; var email = $"{serial}{DeviceEmailDomain}"; return (serial, email); }); public async Task GetByEmail(string? email, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(email)) throw new ArgumentNullException(nameof(email)); return await cache.GetFromCacheAsync(User.GetCacheKey(email), async () => await dbFactory.Run(async db => await db.Users.FirstOrDefaultAsync(x => x.Email == email, ct))); } public async Task ValidateUser(LoginRequest request, CancellationToken ct = default) => await dbFactory.Run(async db => { var user = await db.Users.FirstOrDefaultAsync(x => x.Email == request.Email, token: ct); if (user == null) throw new BusinessException(ExceptionEnum.NoEmailFound); if (request.Password.ToHash() != user.PasswordHash) throw new BusinessException(ExceptionEnum.WrongPassword); if (!user.IsEnabled) throw new BusinessException(ExceptionEnum.UserDisabled); return user; }); public async Task UpdateQueueOffsets(string email, UserQueueOffsets queueOffsets, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => { var userConfig = await db.Users.Where(x => x.Email == email).Select(x => x.UserConfig).FirstOrDefaultAsync(token: ct); userConfig ??= new UserConfig(); userConfig.QueueOffsets = queueOffsets; await db.Users.UpdateAsync(x => x.Email == email, u => new User { UserConfig = userConfig }, token: ct); }); cache.Invalidate(User.GetCacheKey(email)); } public async Task> GetUsers(string? searchEmail, RoleEnum? searchRole, CancellationToken ct) => await dbFactory.Run(async db => await db.Users .WhereIf(!string.IsNullOrEmpty(searchEmail), u => u.Email.ToLower().Contains(searchEmail!.ToLower())) .WhereIf(searchRole != null, u => u.Role == searchRole) .ToListAsync(token: ct)); public async Task ChangeRole(string email, RoleEnum newRole, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == email, u => new User { Role = newRole }, ct)); } public async Task SetEnableStatus(string email, bool isEnabled, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.UpdateAsync(x => x.Email == email, u => new User { IsEnabled = isEnabled }, ct)); } public async Task RemoveUser(string email, CancellationToken ct = default) { await dbFactory.RunAdmin(async db => await db.Users.DeleteAsync(x => x.Email == email, ct)); } }