Initial commit: Lagerbestand Blazor Projekt

This commit is contained in:
spaethf committed 2026-09-21 21:28:23 +02:00
commit cba9414e92
105 files changed
+36431

No files matched your search

+477
View File
@@ -0,0 +1,477 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace Lagerbestand.Services;
public interface IAuthService
{
Task<AppUser?> LoginAsync(string username, string password);
Task<AppUser?> AuthenticateAsync(string username, string password);
Task<AppUser?> GetUserByIdAsync(Guid id);
Task<List<AppUser>> GetAllUsersAsync();
Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role);
Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive);
Task<bool> ChangePasswordAsync(Guid id, string newPassword);
Task DeleteUserAsync(Guid id);
Task EnsureAdminExistsAsync();
Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null);
Task<int> GetActiveAdminCountAsync();
(bool IsValid, string? ErrorMessage) ValidatePassword(string password);
(bool IsValid, string? ErrorMessage) ValidateUsername(string username);
// 2FA methods
Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId);
Task<bool> Confirm2FaAsync(Guid userId, string code);
Task Disable2FaAsync(Guid userId);
Task Reset2FaAsync(Guid userId);
Task SetEnforce2FaAsync(Guid userId, bool enforce);
}
public class AuthService : IAuthService
{
private const string LegacyPasswordSalt = "ReifenSpaethSalt2025";
private const int Pbkdf2Iterations = 100_000;
private const int SaltSize = 16;
private const int HashSize = 32;
private const int MinPasswordLength = 4;
private const int MaxPasswordLength = 128;
private const int MinUsernameLength = 3;
private const int MaxUsernameLength = 50;
private const int MaxFailedLoginAttempts = 5;
private static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(5);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> FailedLoginAttempts = new();
private readonly AppDbContext _context;
private readonly IConfiguration _configuration;
private readonly ILogger<AuthService> _logger;
private readonly ITwoFactorService _twoFactorService;
private readonly ISiteSettingsService _siteSettingsService;
public AuthService(AppDbContext context, IConfiguration configuration, ILogger<AuthService> logger,
ITwoFactorService twoFactorService, ISiteSettingsService siteSettingsService)
{
_context = context;
_configuration = configuration;
_logger = logger;
_twoFactorService = twoFactorService;
_siteSettingsService = siteSettingsService;
}
public async Task<AppUser?> LoginAsync(string username, string password)
{
var lockoutKey = username.Trim().ToLowerInvariant();
if (IsLockedOut(lockoutKey))
{
_logger.LogWarning("Login blocked due to too many failed attempts for username: {Username}", username);
return null;
}
var user = await GetActiveUserByUsernameAsync(username);
if (user == null || !VerifyPassword(password, user.PasswordHash))
{
RegisterFailedAttempt(lockoutKey);
_logger.LogWarning("Failed login attempt for username: {Username}", username);
return null;
}
FailedLoginAttempts.TryRemove(lockoutKey, out _);
// Re-hash legacy SHA256 passwords to PBKDF2
await ReHashIfLegacyAsync(user, password);
user.LastLoginAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("User {Username} logged in successfully", username);
return user;
}
private static bool IsLockedOut(string key)
{
if (FailedLoginAttempts.TryGetValue(key, out var entry))
{
if (entry.Count >= MaxFailedLoginAttempts && DateTime.UtcNow - entry.LastAttempt < LockoutDuration)
return true;
if (DateTime.UtcNow - entry.LastAttempt >= LockoutDuration)
FailedLoginAttempts.TryRemove(key, out _);
}
return false;
}
private static void RegisterFailedAttempt(string key)
{
FailedLoginAttempts.AddOrUpdate(key,
_ => (1, DateTime.UtcNow),
(_, existing) => (existing.Count + 1, DateTime.UtcNow));
}
public async Task<AppUser?> AuthenticateAsync(string username, string password)
{
var user = await GetActiveUserByUsernameAsync(username);
if (user == null || !VerifyPassword(password, user.PasswordHash))
return null;
return user;
}
public async Task<AppUser?> GetUserByIdAsync(Guid id)
{
return await _context.Users.FindAsync(id);
}
public async Task<List<AppUser>> GetAllUsersAsync()
{
return await _context.Users
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task<bool> UsernameExistsAsync(string username, Guid? excludeUserId = null)
{
var normalizedUsername = username.Trim().ToLowerInvariant();
var query = _context.Users.Where(u => u.Username.ToLower() == normalizedUsername);
if (excludeUserId.HasValue)
query = query.Where(u => u.Id != excludeUserId.Value);
return await query.AnyAsync();
}
public async Task<int> GetActiveAdminCountAsync()
{
return await _context.Users
.CountAsync(u => u.Role == UserRole.Admin && u.IsActive);
}
public (bool IsValid, string? ErrorMessage) ValidatePassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
return (false, "Passwort ist erforderlich.");
if (password.Length < MinPasswordLength)
return (false, $"Passwort muss mindestens {MinPasswordLength} Zeichen lang sein.");
if (password.Length > MaxPasswordLength)
return (false, $"Passwort darf maximal {MaxPasswordLength} Zeichen lang sein.");
return (true, null);
}
public (bool IsValid, string? ErrorMessage) ValidateUsername(string username)
{
if (string.IsNullOrWhiteSpace(username))
return (false, "Benutzername ist erforderlich.");
var trimmed = username.Trim();
if (trimmed.Length < MinUsernameLength)
return (false, $"Benutzername muss mindestens {MinUsernameLength} Zeichen lang sein.");
if (trimmed.Length > MaxUsernameLength)
return (false, $"Benutzername darf maximal {MaxUsernameLength} Zeichen lang sein.");
// Allow only alphanumeric, dots, underscores, hyphens
if (!trimmed.All(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-'))
return (false, "Benutzername darf nur Buchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten.");
return (true, null);
}
public async Task<AppUser> CreateUserAsync(string username, string password, string? displayName, UserRole role)
{
var usernameValidation = ValidateUsername(username);
if (!usernameValidation.IsValid)
throw new InvalidOperationException(usernameValidation.ErrorMessage);
var passwordValidation = ValidatePassword(password);
if (!passwordValidation.IsValid)
throw new InvalidOperationException(passwordValidation.ErrorMessage);
var normalizedUsername = username.Trim();
if (await UsernameExistsAsync(normalizedUsername))
throw new InvalidOperationException($"Benutzer '{normalizedUsername}' existiert bereits.");
var user = new AppUser
{
Username = normalizedUsername,
PasswordHash = HashPassword(password),
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
Role = role,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
_logger.LogInformation("User {Username} created with role {Role}", normalizedUsername, role);
return user;
}
public async Task<AppUser> UpdateUserAsync(Guid id, string? username, string? displayName, UserRole role, bool isActive)
{
var user = await _context.Users.FindAsync(id)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
// Prevent deactivating or demoting the last admin
if (user.Role == UserRole.Admin && (role != UserRole.Admin || !isActive))
{
var adminCount = await GetActiveAdminCountAsync();
if (adminCount <= 1)
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht deaktiviert oder zu einer anderen Rolle geändert werden.");
}
// Update username if provided and different
if (!string.IsNullOrWhiteSpace(username))
{
var newUsername = username.Trim();
if (!newUsername.Equals(user.Username, StringComparison.OrdinalIgnoreCase))
{
var usernameValidation = ValidateUsername(newUsername);
if (!usernameValidation.IsValid)
throw new InvalidOperationException(usernameValidation.ErrorMessage);
if (await UsernameExistsAsync(newUsername, id))
throw new InvalidOperationException($"Benutzer '{newUsername}' existiert bereits.");
user.Username = newUsername;
}
}
user.DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim();
user.Role = role;
user.IsActive = isActive;
await _context.SaveChangesAsync();
_logger.LogInformation("User {UserId} updated", id);
return user;
}
public async Task<bool> ChangePasswordAsync(Guid id, string newPassword)
{
var passwordValidation = ValidatePassword(newPassword);
if (!passwordValidation.IsValid)
throw new InvalidOperationException(passwordValidation.ErrorMessage);
var user = await _context.Users.FindAsync(id);
if (user == null)
return false;
user.PasswordHash = HashPassword(newPassword);
await _context.SaveChangesAsync();
_logger.LogInformation("Password changed for user {UserId}", id);
return true;
}
public async Task DeleteUserAsync(Guid id)
{
var user = await _context.Users.FindAsync(id);
if (user == null)
return;
// Prevent deleting the last admin
if (user.Role == UserRole.Admin && user.IsActive)
{
var adminCount = await GetActiveAdminCountAsync();
if (adminCount <= 1)
throw new InvalidOperationException("Der letzte aktive Administrator kann nicht gelöscht werden.");
}
// Soft delete
user.IsActive = false;
await _context.SaveChangesAsync();
_logger.LogInformation("User {UserId} ({Username}) deactivated", id, user.Username);
}
public async Task EnsureAdminExistsAsync()
{
try
{
var adminUsername = _configuration["AdminUser:Username"] ?? "admin";
var adminPassword = _configuration["AdminUser:Password"] ?? "admin123";
var adminDisplayName = _configuration["AdminUser:DisplayName"] ?? "Administrator";
var existingAdmin = await _context.Users
.FirstOrDefaultAsync(u => u.Username.ToLower() == adminUsername.ToLower());
if (existingAdmin == null)
{
var admin = new AppUser
{
Username = adminUsername,
PasswordHash = HashPassword(adminPassword),
DisplayName = adminDisplayName,
Role = UserRole.Admin,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Users.Add(admin);
await _context.SaveChangesAsync();
_logger.LogInformation("Admin user '{Username}' created.", adminUsername);
}
else
{
existingAdmin.PasswordHash = HashPassword(adminPassword);
existingAdmin.DisplayName = adminDisplayName;
existingAdmin.Role = UserRole.Admin;
existingAdmin.IsActive = true;
await _context.SaveChangesAsync();
_logger.LogInformation("Admin user '{Username}' updated.", adminUsername);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating/updating admin user.");
}
}
private async Task<AppUser?> GetActiveUserByUsernameAsync(string username)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower() && u.IsActive);
}
private static string HashPassword(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
Pbkdf2Iterations,
HashAlgorithmName.SHA256,
HashSize);
return $"{Pbkdf2Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
private static bool VerifyPassword(string password, string storedHash)
{
// New PBKDF2 format: "iterations.salt.hash"
if (storedHash.Contains('.'))
{
var parts = storedHash.Split('.', 3);
if (parts.Length == 3
&& int.TryParse(parts[0], out var iterations))
{
var salt = Convert.FromBase64String(parts[1]);
var expectedHash = Convert.FromBase64String(parts[2]);
var computedHash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt,
iterations,
HashAlgorithmName.SHA256,
expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(computedHash, expectedHash);
}
}
// Legacy SHA256 + hardcoded salt fallback
var legacyBytes = Encoding.UTF8.GetBytes(password + LegacyPasswordSalt);
var legacyHash = SHA256.HashData(legacyBytes);
return Convert.ToBase64String(legacyHash) == storedHash;
}
/// <summary>
/// Re-hashes a legacy password to the new PBKDF2 format on successful login.
/// </summary>
private async Task ReHashIfLegacyAsync(AppUser user, string password)
{
// If the stored hash is in legacy format (no dots), upgrade it
if (!user.PasswordHash.Contains('.'))
{
user.PasswordHash = HashPassword(password);
await _context.SaveChangesAsync();
_logger.LogInformation("Re-hashed legacy password for user {Username}", user.Username);
}
}
#region Two-Factor Authentication
public async Task<(string Secret, string OtpAuthUri)> Enable2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
var tfSettings = await _siteSettingsService.GetTwoFactorSettingsAsync();
if (!tfSettings.IsEnabled)
throw new InvalidOperationException("Zwei-Faktor-Authentifizierung ist global deaktiviert.");
var secret = _twoFactorService.GenerateSecretKey();
var otpAuthUri = _twoFactorService.GenerateOtpAuthUri(secret, user.Username, tfSettings.Issuer);
// Store the secret but don't enable 2FA yet – user must confirm with a code first
user.TwoFactorSecret = secret;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA setup initiated for user {Username}", user.Username);
return (secret, otpAuthUri);
}
public async Task<bool> Confirm2FaAsync(Guid userId, string code)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
if (string.IsNullOrEmpty(user.TwoFactorSecret))
throw new InvalidOperationException("Kein 2FA-Secret vorhanden. Bitte 2FA zuerst einrichten.");
if (!_twoFactorService.ValidateTotpCode(user.TwoFactorSecret, code))
return false;
user.TwoFactorEnabled = true;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA confirmed and enabled for user {Username}", user.Username);
return true;
}
public async Task Disable2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
if (user.TwoFactorEnforced)
throw new InvalidOperationException("2FA wurde vom Administrator erzwungen und kann nicht deaktiviert werden.");
user.TwoFactorEnabled = false;
user.TwoFactorSecret = null;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
}
public async Task Reset2FaAsync(Guid userId)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
user.TwoFactorEnabled = false;
user.TwoFactorSecret = null;
user.TwoFactorEnforced = false;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA reset by admin for user {Username}", user.Username);
}
public async Task SetEnforce2FaAsync(Guid userId, bool enforce)
{
var user = await _context.Users.FindAsync(userId)
?? throw new InvalidOperationException("Benutzer nicht gefunden.");
user.TwoFactorEnforced = enforce;
await _context.SaveChangesAsync();
_logger.LogInformation("2FA enforce set to {Enforce} for user {Username}", enforce, user.Username);
}
#endregion
}
+114
View File
@@ -0,0 +1,114 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ICatalogService
{
Task<List<CatalogItem>> GetAllAsync();
Task<CatalogItem?> GetByIdAsync(Guid id);
Task<CatalogItem> CreateAsync(CatalogItem item);
Task<CatalogItem> UpdateAsync(CatalogItem item);
Task DeleteAsync(Guid id);
Task<string> SaveImageAsync(Stream imageStream, string fileName);
Task DeleteImageAsync(string? fileName);
}
public class CatalogService : ICatalogService
{
private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private readonly AppDbContext _context;
private readonly ILogger<CatalogService> _logger;
private readonly string _uploadPath;
public CatalogService(AppDbContext context, IWebHostEnvironment environment, ILogger<CatalogService> logger)
{
_context = context;
_logger = logger;
_uploadPath = Path.Combine(environment.WebRootPath, "uploads", "images");
Directory.CreateDirectory(_uploadPath);
}
public async Task<List<CatalogItem>> GetAllAsync()
{
return await _context.CatalogItems
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
}
public async Task<CatalogItem?> GetByIdAsync(Guid id)
{
return await _context.CatalogItems.FindAsync(id);
}
public async Task<CatalogItem> CreateAsync(CatalogItem item)
{
item.Id = Guid.NewGuid();
item.CreatedAt = DateTime.UtcNow;
_context.CatalogItems.Add(item);
await _context.SaveChangesAsync();
return item;
}
public async Task<CatalogItem> UpdateAsync(CatalogItem item)
{
var existing = await _context.CatalogItems.FindAsync(item.Id)
?? throw new InvalidOperationException("Artikel nicht gefunden");
existing.Name = item.Name;
existing.TireSize = item.TireSize;
existing.Season = item.Season;
existing.Type = item.Type;
existing.PictureFileName = item.PictureFileName;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteAsync(Guid id)
{
var item = await _context.CatalogItems.FindAsync(id);
if (item != null)
{
await DeleteImageAsync(item.PictureFileName);
_context.CatalogItems.Remove(item);
await _context.SaveChangesAsync();
}
}
public async Task<string> SaveImageAsync(Stream imageStream, string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
if (!AllowedImageExtensions.Contains(extension))
throw new InvalidOperationException("Ungültiger Dateityp. Erlaubt: jpg, jpeg, png, gif, webp");
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(_uploadPath, uniqueFileName);
await using var fileStream = new FileStream(filePath, FileMode.Create);
await imageStream.CopyToAsync(fileStream);
return uniqueFileName;
}
public Task DeleteImageAsync(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
return Task.CompletedTask;
var filePath = Path.Combine(_uploadPath, fileName);
if (File.Exists(filePath))
File.Delete(filePath);
return Task.CompletedTask;
}
}
+315
View File
@@ -0,0 +1,315 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ICustomerService
{
Task<List<Customer>> GetAllAsync(bool includeInactive = false);
Task<Customer?> GetByIdAsync(Guid id);
Task<Customer?> GetByCustomerNumberAsync(string customerNumber);
Task<List<Customer>> SearchAsync(string searchTerm);
Task<Customer?> FindByVehicleAsync(string searchTerm);
Task<Customer> CreateAsync(Customer customer);
Task<Customer> UpdateAsync(Customer customer);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateCustomerNumberAsync();
Task<int> GetStoredTireStackCountAsync(Guid customerId);
// Vehicle methods
Task<List<Vehicle>> GetVehiclesAsync(Guid customerId);
Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId);
Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle);
Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle);
Task<bool> DeleteVehicleAsync(Guid vehicleId);
Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId);
Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle);
}
public class CustomerService : ICustomerService
{
private readonly AppDbContext _context;
private readonly ILogger<CustomerService> _logger;
public CustomerService(AppDbContext context, ILogger<CustomerService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<Customer>> GetAllAsync(bool includeInactive = false)
{
var query = _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.AsQueryable();
if (!includeInactive)
query = query.Where(c => c.IsActive);
return await query
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.ToListAsync();
}
public async Task<Customer?> GetByIdAsync(Guid id)
{
return await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.ThenInclude(ts => ts.StorageLocation)
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.ThenInclude(ts => ts.Vehicle)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<Customer?> GetByCustomerNumberAsync(string customerNumber)
{
return await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber);
}
public async Task<List<Customer>> SearchAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllAsync();
var term = searchTerm.Trim().ToLower();
return await _context.Customers
.AsNoTracking()
.AsSplitQuery()
.Include(c => c.Vehicles.Where(v => v.IsActive))
.Include(c => c.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.Where(c => c.IsActive && (
c.FirstName != null && c.FirstName.ToLower().Contains(term) ||
c.LastName.ToLower().Contains(term) ||
(c.Company != null && c.Company.ToLower().Contains(term)) ||
c.CustomerNumber.ToLower().Contains(term) ||
(c.Phone != null && c.Phone.Contains(term)) ||
// Search in vehicles
c.Vehicles.Any(v => v.IsActive && (
v.Make.ToLower().Contains(term) ||
(v.Model != null && v.Model.ToLower().Contains(term))))
))
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.Take(50)
.ToListAsync();
}
public async Task<Customer?> FindByVehicleAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return null;
var term = searchTerm.Trim().ToLower();
// Server-side search in Vehicles table by Make or Model
var matchingVehicle = await _context.Vehicles
.Include(v => v.Customer)
.Where(v => v.IsActive && v.Customer != null && v.Customer.IsActive)
.FirstOrDefaultAsync(v =>
v.Make.ToLower().Contains(term) ||
(v.Model != null && v.Model.ToLower().Contains(term)));
if (matchingVehicle?.Customer != null)
{
return await GetByIdAsync(matchingVehicle.Customer.Id);
}
return null;
}
public async Task<Customer> CreateAsync(Customer customer)
{
if (string.IsNullOrEmpty(customer.CustomerNumber))
customer.CustomerNumber = await GenerateCustomerNumberAsync();
customer.CreatedAt = DateTime.UtcNow;
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer created: {CustomerNumber} - {Name}",
customer.CustomerNumber, customer.FullName);
return customer;
}
public async Task<Customer> CreateWithVehicleAsync(Customer customer, Vehicle vehicle)
{
if (string.IsNullOrEmpty(customer.CustomerNumber))
customer.CustomerNumber = await GenerateCustomerNumberAsync();
customer.CreatedAt = DateTime.UtcNow;
vehicle.IsPrimary = true;
vehicle.CreatedAt = DateTime.UtcNow;
customer.Vehicles.Add(vehicle);
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer created with vehicle: {CustomerNumber} - {Name}, Vehicle: {Make}",
customer.CustomerNumber, customer.FullName, vehicle.Make);
return customer;
}
public async Task<Customer> UpdateAsync(Customer customer)
{
customer.UpdatedAt = DateTime.UtcNow;
_context.Customers.Update(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Customer updated: {CustomerNumber}", customer.CustomerNumber);
return customer;
}
public async Task<bool> DeleteAsync(Guid id)
{
var customer = await _context.Customers.FindAsync(id);
if (customer == null)
return false;
// Soft delete - just mark as inactive
customer.IsActive = false;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Customer deactivated: {CustomerNumber}", customer.CustomerNumber);
return true;
}
public async Task<string> GenerateCustomerNumberAsync()
{
var year = DateTime.UtcNow.Year;
var prefix = $"K-{year}-";
var lastNumber = await _context.Customers
.Where(c => c.CustomerNumber.StartsWith(prefix))
.OrderByDescending(c => c.CustomerNumber)
.Select(c => c.CustomerNumber)
.FirstOrDefaultAsync();
var nextNumber = 1;
if (lastNumber != null)
{
var numPart = lastNumber.Replace(prefix, "");
if (int.TryParse(numPart, out var lastNum))
nextNumber = lastNum + 1;
}
return $"{prefix}{nextNumber:D4}";
}
public async Task<int> GetStoredTireStackCountAsync(Guid customerId)
{
return await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.CustomerId == customerId && ts.Status == TireStackStatus.Stored);
}
// Vehicle methods
public async Task<List<Vehicle>> GetVehiclesAsync(Guid customerId)
{
return await _context.Vehicles
.Where(v => v.CustomerId == customerId && v.IsActive)
.OrderByDescending(v => v.IsPrimary)
.ThenBy(v => v.CreatedAt)
.ToListAsync();
}
public async Task<Vehicle?> GetVehicleByIdAsync(Guid vehicleId)
{
return await _context.Vehicles
.Include(v => v.Customer)
.Include(v => v.TireStacks.Where(ts => ts.Status == TireStackStatus.Stored))
.FirstOrDefaultAsync(v => v.Id == vehicleId);
}
public async Task<Vehicle> AddVehicleAsync(Guid customerId, Vehicle vehicle)
{
vehicle.CustomerId = customerId;
vehicle.CreatedAt = DateTime.UtcNow;
// If this is the first vehicle, make it primary
var existingVehicles = await _context.Vehicles
.CountAsync(v => v.CustomerId == customerId && v.IsActive);
if (existingVehicles == 0)
vehicle.IsPrimary = true;
_context.Vehicles.Add(vehicle);
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle added for customer {CustomerId}: {Make}",
customerId, vehicle.Make);
return vehicle;
}
public async Task<Vehicle> UpdateVehicleAsync(Vehicle vehicle)
{
vehicle.UpdatedAt = DateTime.UtcNow;
_context.Vehicles.Update(vehicle);
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle updated: {VehicleId} - {Make}",
vehicle.Id, vehicle.Make);
return vehicle;
}
public async Task<bool> DeleteVehicleAsync(Guid vehicleId)
{
var vehicle = await _context.Vehicles.FindAsync(vehicleId);
if (vehicle == null)
return false;
// Soft delete
vehicle.IsActive = false;
vehicle.UpdatedAt = DateTime.UtcNow;
// If this was primary, assign primary to another vehicle
if (vehicle.IsPrimary)
{
vehicle.IsPrimary = false;
var nextVehicle = await _context.Vehicles
.Where(v => v.CustomerId == vehicle.CustomerId && v.IsActive && v.Id != vehicleId)
.FirstOrDefaultAsync();
if (nextVehicle != null)
nextVehicle.IsPrimary = true;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Vehicle deactivated: {VehicleId}", vehicleId);
return true;
}
public async Task SetPrimaryVehicleAsync(Guid customerId, Guid vehicleId)
{
var vehicles = await _context.Vehicles
.Where(v => v.CustomerId == customerId && v.IsActive)
.ToListAsync();
foreach (var v in vehicles)
{
v.IsPrimary = v.Id == vehicleId;
v.UpdatedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Primary vehicle set for customer {CustomerId}: {VehicleId}",
customerId, vehicleId);
}
}
File diff suppressed because it is too large. Load diff
+254
View File
@@ -0,0 +1,254 @@
using Lagerbestand.Models;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Lagerbestand.Services;
/// <summary>
/// File storage service for disk/NFS-based file operations
/// </summary>
public class FileStorageService : IFileStorageService
{
private readonly ISiteSettingsService _siteSettingsService;
private readonly ILogger<FileStorageService> _logger;
private const string SettingsKey = "FileStorage";
public FileStorageService(ISiteSettingsService siteSettingsService, ILogger<FileStorageService> logger)
{
_siteSettingsService = siteSettingsService;
_logger = logger;
}
public async Task<FileStorageSettings> GetSettingsAsync()
{
try
{
var json = await _siteSettingsService.GetValueAsync(SettingsKey);
if (string.IsNullOrEmpty(json))
{
return new FileStorageSettings();
}
return JsonSerializer.Deserialize<FileStorageSettings>(json) ?? new FileStorageSettings();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading file storage settings");
return new FileStorageSettings();
}
}
public async Task SaveSettingsAsync(FileStorageSettings settings)
{
var json = JsonSerializer.Serialize(settings);
await _siteSettingsService.SetValueAsync(SettingsKey, json);
_logger.LogInformation("File storage settings saved. Path: {BasePath}, Enabled: {IsEnabled}",
settings.BasePath, settings.IsEnabled);
}
public Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return Task.FromResult((false, (string?)"Der Pfad darf nicht leer sein."));
}
try
{
// Check if directory exists or can be created
if (!Directory.Exists(path))
{
try
{
Directory.CreateDirectory(path);
_logger.LogInformation("Created storage directory: {Path}", path);
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Verzeichnis kann nicht erstellt werden: {ex.Message}"));
}
}
// Test write access
var testFile = Path.Combine(path, $".write_test_{Guid.NewGuid():N}");
try
{
File.WriteAllText(testFile, "test");
File.Delete(testFile);
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Keine Schreibrechte: {ex.Message}"));
}
return Task.FromResult((true, (string?)null));
}
catch (Exception ex)
{
return Task.FromResult((false, (string?)$"Fehler: {ex.Message}"));
}
}
public async Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments")
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
throw new InvalidOperationException("Dateispeicher ist nicht konfiguriert.");
}
// Create unique filename to avoid collisions
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
var uniqueId = Guid.NewGuid().ToString("N")[..8];
var safeFileName = SanitizeFileName(fileName);
var extension = Path.GetExtension(safeFileName);
var nameWithoutExt = Path.GetFileNameWithoutExtension(safeFileName);
var finalFileName = $"{nameWithoutExt}_{timestamp}_{uniqueId}{extension}";
// Create subdirectory path with year/month structure
var datePath = DateTime.UtcNow.ToString("yyyy/MM");
var relativePath = Path.Combine(subDirectory, datePath, finalFileName);
var fullPath = Path.Combine(settings.BasePath, relativePath);
// Ensure directory exists
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
await File.WriteAllBytesAsync(fullPath, content);
_logger.LogInformation("File saved: {RelativePath} ({Size} bytes)", relativePath, content.Length);
return relativePath;
}
public async Task<byte[]?> ReadFileAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return null;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null || !File.Exists(fullPath))
{
_logger.LogWarning("File not found: {RelativePath}", relativePath);
return null;
}
return await File.ReadAllBytesAsync(fullPath);
}
public async Task<bool> DeleteFileAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return false;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null)
{
return false;
}
if (!File.Exists(fullPath))
{
return true; // Already deleted
}
try
{
File.Delete(fullPath);
_logger.LogInformation("File deleted: {RelativePath}", relativePath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting file: {RelativePath}", relativePath);
return false;
}
}
public async Task<bool> FileExistsAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return false;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
return await Task.FromResult(fullPath != null && File.Exists(fullPath));
}
public async Task<long> GetFileSizeAsync(string relativePath)
{
var settings = await GetSettingsAsync();
if (!settings.IsEnabled || string.IsNullOrWhiteSpace(settings.BasePath))
{
return 0;
}
var fullPath = ResolveSafePath(settings.BasePath, relativePath);
if (fullPath == null || !File.Exists(fullPath))
{
return 0;
}
var fileInfo = new FileInfo(fullPath);
return await Task.FromResult(fileInfo.Length);
}
/// <summary>
/// Resolves a relative path against the base path and blocks path traversal attempts.
/// Returns null if the resolved path escapes the base directory.
/// </summary>
private string? ResolveSafePath(string basePath, string relativePath)
{
try
{
var fullBasePath = Path.GetFullPath(basePath);
var fullPath = Path.GetFullPath(Path.Combine(fullBasePath, relativePath));
if (!fullPath.StartsWith(fullBasePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Path traversal attempt blocked: {RelativePath}", relativePath);
return null;
}
return fullPath;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Invalid path rejected: {RelativePath}", relativePath);
return null;
}
}
private static string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
// Limit length
if (sanitized.Length > 200)
{
var ext = Path.GetExtension(sanitized);
var name = Path.GetFileNameWithoutExtension(sanitized);
sanitized = name[..(200 - ext.Length)] + ext;
}
return sanitized;
}
}
@@ -0,0 +1,49 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Service interface for file storage operations (disk/NFS based)
/// </summary>
public interface IFileStorageService
{
/// <summary>
/// Gets the current storage settings
/// </summary>
Task<FileStorageSettings> GetSettingsAsync();
/// <summary>
/// Updates the storage settings
/// </summary>
Task SaveSettingsAsync(FileStorageSettings settings);
/// <summary>
/// Validates if the storage path is accessible and writable
/// </summary>
Task<(bool IsValid, string? ErrorMessage)> ValidateStoragePathAsync(string path);
/// <summary>
/// Saves a file to storage and returns the relative path
/// </summary>
Task<string> SaveFileAsync(byte[] content, string fileName, string subDirectory = "attachments");
/// <summary>
/// Reads a file from storage
/// </summary>
Task<byte[]?> ReadFileAsync(string relativePath);
/// <summary>
/// Deletes a file from storage
/// </summary>
Task<bool> DeleteFileAsync(string relativePath);
/// <summary>
/// Checks if a file exists in storage
/// </summary>
Task<bool> FileExistsAsync(string relativePath);
/// <summary>
/// Gets the file size in bytes
/// </summary>
Task<long> GetFileSizeAsync(string relativePath);
}
+98
View File
@@ -0,0 +1,98 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
public interface IKanbanService
{
// Columns
Task<List<KanbanColumn>> GetColumnsAsync();
Task<KanbanColumn?> GetColumnByIdAsync(Guid id);
Task<KanbanColumn> CreateColumnAsync(KanbanColumn column);
Task<KanbanColumn> UpdateColumnAsync(KanbanColumn column);
Task DeleteColumnAsync(Guid id);
Task ReorderColumnsAsync(List<Guid> columnIds);
// Labels
Task<List<KanbanLabel>> GetLabelsAsync();
Task<KanbanLabel?> GetLabelByIdAsync(Guid id);
Task<KanbanLabel> CreateLabelAsync(KanbanLabel label);
Task<KanbanLabel> UpdateLabelAsync(KanbanLabel label);
Task DeleteLabelAsync(Guid id);
// Tasks
Task<List<KanbanTask>> GetTasksAsync(bool includeArchived = false);
Task<List<KanbanTask>> GetTasksByColumnAsync(Guid columnId);
Task<KanbanTask?> GetTaskByIdAsync(Guid id);
Task<KanbanTask> CreateTaskAsync(KanbanTask task, Guid? userId = null);
Task<KanbanTask> CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null);
Task<KanbanTask> UpdateTaskAsync(KanbanTask task, Guid? userId = null);
Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null);
Task DeleteTaskAsync(Guid id);
Task ArchiveTaskAsync(Guid id);
Task UnarchiveTaskAsync(Guid id);
Task<List<KanbanTask>> GetRecentlyArchivedTasksAsync(int days = 7);
Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30);
// Task Todos
Task<KanbanTaskTodo> AddTodoAsync(Guid taskId, string text);
Task<KanbanTaskTodo> UpdateTodoAsync(KanbanTaskTodo todo);
Task ToggleTodoAsync(Guid todoId);
Task DeleteTodoAsync(Guid todoId);
Task ReorderTodosAsync(Guid taskId, List<Guid> todoIds);
// Task Comments/Chat
Task<List<KanbanTaskComment>> GetCommentsAsync(Guid taskId);
Task<KanbanTaskComment> AddCommentAsync(Guid taskId, string content, Guid? userId);
Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null);
// Task Notes
Task<List<KanbanTaskNote>> GetNotesAsync(Guid taskId);
Task<KanbanTaskNote> AddNoteAsync(Guid taskId, string title, string content);
Task<KanbanTaskNote> UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null);
Task DeleteNoteAsync(Guid noteId);
// Task Attachments
Task<List<KanbanTaskAttachment>> GetAttachmentsAsync(Guid taskId);
Task<KanbanTaskAttachment?> GetAttachmentByIdAsync(Guid id);
Task<KanbanTaskAttachment> AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId);
Task<byte[]?> GetAttachmentContentAsync(Guid attachmentId);
Task DeleteAttachmentAsync(Guid attachmentId);
// Task Labels
Task AddLabelToTaskAsync(Guid taskId, Guid labelId);
Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId);
Task SetTaskLabelsAsync(Guid taskId, List<Guid> labelIds);
// Task Assignees
Task AddAssigneeAsync(Guid taskId, Guid userId);
Task RemoveAssigneeAsync(Guid taskId, Guid userId);
Task SetAssigneesAsync(Guid taskId, List<Guid> userIds, Guid? changedByUserId = null);
// Task Customers
Task AddCustomerToTaskAsync(Guid taskId, Guid customerId);
Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId);
Task SetTaskCustomersAsync(Guid taskId, Dictionary<Guid, Guid?> customerVehicleMap);
// Task TireStacks
Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId);
Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId);
Task SetTaskTireStacksAsync(Guid taskId, List<Guid> tireStackIds);
// Templates
Task<List<KanbanTaskTemplate>> GetTemplatesAsync();
Task<KanbanTaskTemplate?> GetTemplateByIdAsync(Guid id);
Task<KanbanTaskTemplate> CreateTemplateAsync(KanbanTaskTemplate template);
Task<KanbanTaskTemplate> UpdateTemplateAsync(KanbanTaskTemplate template);
Task DeleteTemplateAsync(Guid id);
// Template Todos
Task<KanbanTaskTemplateTodo> AddTemplateTodoAsync(Guid templateId, string text);
Task DeleteTemplateTodoAsync(Guid todoId);
Task SyncTemplateTodosAsync(Guid templateId, List<KanbanTaskTemplateTodo> todos);
// Template Labels
Task SetTemplateLabelsAsync(Guid templateId, List<Guid> labelIds);
// Statistics/Dashboard
Task<KanbanStatistics> GetStatisticsAsync();
}
+189
View File
@@ -0,0 +1,189 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface IInventoryService
{
Task<List<InventoryItem>> GetAllAsync();
Task<InventoryItem?> GetByIdAsync(Guid id);
Task<InventoryItem?> GetByQrCodeAsync(string qrCode);
Task<bool> ExistsAsync(string qrCode);
Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null);
Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null);
Task<bool> RemoveItemAsync(string qrCode);
Task<int> RemoveItemsAsync(List<string> qrCodes);
Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId);
Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId);
Task DeleteAsync(Guid id);
}
public class InventoryService : IInventoryService
{
private readonly AppDbContext _context;
private readonly ILogger<InventoryService> _logger;
public InventoryService(AppDbContext context, ILogger<InventoryService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<InventoryItem>> GetAllAsync()
{
return await _context.InventoryItems
.AsNoTracking()
.Include(x => x.CatalogItem)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync();
}
public async Task<InventoryItem?> GetByIdAsync(Guid id)
{
return await _context.InventoryItems
.Include(x => x.CatalogItem)
.FirstOrDefaultAsync(x => x.Id == id);
}
public async Task<InventoryItem?> GetByQrCodeAsync(string qrCode)
{
return await _context.InventoryItems
.Include(x => x.CatalogItem)
.FirstOrDefaultAsync(x => x.QrCode == qrCode);
}
public async Task<bool> ExistsAsync(string qrCode)
{
return await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode);
}
public async Task<InventoryItem> AddItemAsync(string qrCode, Guid? catalogItemId = null, string? name = null)
{
if (await _context.InventoryItems.AnyAsync(x => x.QrCode == qrCode))
throw new InvalidOperationException($"Artikel mit QR-Code '{qrCode}' ist bereits im Bestand.");
var item = new InventoryItem
{
Id = Guid.NewGuid(),
QrCode = qrCode,
CatalogItemId = catalogItemId,
Name = name,
CreatedAt = DateTime.UtcNow
};
_context.InventoryItems.Add(item);
await _context.SaveChangesAsync();
return await GetByIdAsync(item.Id) ?? item;
}
public async Task<List<InventoryItem>> AddItemsAsync(List<string> qrCodes, Guid? catalogItemId = null)
{
var addedItems = new List<InventoryItem>();
var now = DateTime.UtcNow;
var distinctCodes = qrCodes.Distinct().ToList();
var existingCodes = await _context.InventoryItems
.Where(x => distinctCodes.Contains(x.QrCode))
.Select(x => x.QrCode)
.ToListAsync();
var existingSet = existingCodes.ToHashSet();
foreach (var qrCode in distinctCodes)
{
if (existingSet.Contains(qrCode))
continue;
var item = new InventoryItem
{
Id = Guid.NewGuid(),
QrCode = qrCode,
CatalogItemId = catalogItemId,
CreatedAt = now
};
_context.InventoryItems.Add(item);
addedItems.Add(item);
}
if (addedItems.Count > 0)
await _context.SaveChangesAsync();
return addedItems;
}
public async Task<bool> RemoveItemAsync(string qrCode)
{
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
if (item == null)
return false;
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync();
return true;
}
public async Task<int> RemoveItemsAsync(List<string> qrCodes)
{
var count = 0;
foreach (var qrCode in qrCodes.Distinct())
{
var item = await _context.InventoryItems.FirstOrDefaultAsync(x => x.QrCode == qrCode);
if (item != null)
{
_context.InventoryItems.Remove(item);
count++;
}
}
if (count > 0)
await _context.SaveChangesAsync();
return count;
}
public async Task<InventoryItem> UpdateCategoryAsync(Guid id, Guid? catalogItemId)
{
var item = await _context.InventoryItems.FindAsync(id)
?? throw new InvalidOperationException("Artikel nicht gefunden");
item.CatalogItemId = catalogItemId;
item.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return item;
}
public async Task<int> UpdateCategoryForItemsAsync(List<Guid> itemIds, Guid? catalogItemId)
{
var now = DateTime.UtcNow;
var count = 0;
foreach (var id in itemIds)
{
var item = await _context.InventoryItems.FindAsync(id);
if (item != null)
{
item.CatalogItemId = catalogItemId;
item.UpdatedAt = now;
count++;
}
}
if (count > 0)
await _context.SaveChangesAsync();
return count;
}
public async Task DeleteAsync(Guid id)
{
var item = await _context.InventoryItems.FindAsync(id);
if (item != null)
{
_context.InventoryItems.Remove(item);
await _context.SaveChangesAsync();
}
}
}
File diff suppressed because it is too large. Load diff
+515
View File
@@ -0,0 +1,515 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of a Lexware customer sync operation
/// </summary>
public class LexwareSyncResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int CustomersCreated { get; set; }
public int CustomersUpdated { get; set; }
public int CustomersDeactivated { get; set; }
public int TotalProcessed { get; set; }
}
/// <summary>
/// Lexware API contact response models
/// </summary>
public class LexwareContactsResponse
{
[JsonPropertyName("content")]
public List<LexwareContact> Content { get; set; } = new();
[JsonPropertyName("totalPages")]
public int TotalPages { get; set; }
[JsonPropertyName("totalElements")]
public int TotalElements { get; set; }
[JsonPropertyName("last")]
public bool Last { get; set; }
[JsonPropertyName("number")]
public int Number { get; set; }
}
public class LexwareContact
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("organizationId")]
public string? OrganizationId { get; set; }
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("roles")]
public LexwareRoles? Roles { get; set; }
[JsonPropertyName("company")]
public LexwareCompany? Company { get; set; }
[JsonPropertyName("person")]
public LexwarePerson? Person { get; set; }
[JsonPropertyName("addresses")]
public LexwareAddresses? Addresses { get; set; }
[JsonPropertyName("emailAddresses")]
public LexwareEmailAddresses? EmailAddresses { get; set; }
[JsonPropertyName("phoneNumbers")]
public LexwarePhoneNumbers? PhoneNumbers { get; set; }
[JsonPropertyName("note")]
public string? Note { get; set; }
[JsonPropertyName("archived")]
public bool Archived { get; set; }
}
public class LexwareRoles
{
[JsonPropertyName("customer")]
public LexwareCustomerRole? Customer { get; set; }
}
public class LexwareCustomerRole
{
[JsonPropertyName("number")]
public long? Number { get; set; }
}
public class LexwareCompany
{
[JsonPropertyName("name")]
public string? Name { get; set; }
}
public class LexwarePerson
{
[JsonPropertyName("salutation")]
public string? Salutation { get; set; }
[JsonPropertyName("firstName")]
public string? FirstName { get; set; }
[JsonPropertyName("lastName")]
public string? LastName { get; set; }
}
public class LexwareAddresses
{
[JsonPropertyName("billing")]
public List<LexwareAddress>? Billing { get; set; }
}
public class LexwareAddress
{
[JsonPropertyName("street")]
public string? Street { get; set; }
[JsonPropertyName("zip")]
public string? Zip { get; set; }
[JsonPropertyName("city")]
public string? City { get; set; }
[JsonPropertyName("countryCode")]
public string? CountryCode { get; set; }
}
public class LexwareEmailAddresses
{
[JsonPropertyName("business")]
public List<string>? Business { get; set; }
[JsonPropertyName("private")]
public List<string>? Private { get; set; }
[JsonPropertyName("other")]
public List<string>? Other { get; set; }
}
public class LexwarePhoneNumbers
{
[JsonPropertyName("business")]
public List<string>? Business { get; set; }
[JsonPropertyName("private")]
public List<string>? Private { get; set; }
[JsonPropertyName("mobile")]
public List<string>? Mobile { get; set; }
}
public interface ILexwareSyncService
{
/// <summary>
/// Tests the API connection with the given settings
/// </summary>
Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings);
/// <summary>
/// Syncs customers from Lexware API to the local database
/// </summary>
Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default);
}
public class LexwareSyncService : ILexwareSyncService
{
private readonly AppDbContext _context;
private readonly ISiteSettingsService _siteSettingsService;
private readonly ICustomerService _customerService;
private readonly ILogger<LexwareSyncService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public LexwareSyncService(
AppDbContext context,
ISiteSettingsService siteSettingsService,
ICustomerService customerService,
ILogger<LexwareSyncService> logger,
IHttpClientFactory httpClientFactory)
{
_context = context;
_siteSettingsService = siteSettingsService;
_customerService = customerService;
_logger = logger;
_httpClientFactory = httpClientFactory;
}
public async Task<(bool Success, string Message)> TestConnectionAsync(LexwareApiSettings settings)
{
if (!settings.IsConfigured)
return (false, "API URL und API-Key müssen konfiguriert sein.");
try
{
using var client = CreateHttpClient(settings);
var response = await client.GetAsync("contacts?page=0&size=1");
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
return (true, $"Verbindung erfolgreich! {result?.TotalElements ?? 0} Kontakte gefunden.");
}
return response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => (false, "Authentifizierung fehlgeschlagen. Bitte API-Key prüfen."),
System.Net.HttpStatusCode.Forbidden => (false, "Zugriff verweigert. API-Key hat keine ausreichenden Berechtigungen."),
System.Net.HttpStatusCode.NotFound => (false, "API-Endpunkt nicht gefunden. Bitte URL prüfen."),
_ => (false, $"API-Fehler: {response.StatusCode} - {await response.Content.ReadAsStringAsync()}")
};
}
catch (HttpRequestException ex)
{
return (false, $"Verbindungsfehler: {ex.Message}");
}
catch (TaskCanceledException)
{
return (false, "Zeitüberschreitung bei der Verbindung.");
}
catch (Exception ex)
{
return (false, $"Fehler: {ex.Message}");
}
}
public async Task<LexwareSyncResult> SyncCustomersAsync(CancellationToken cancellationToken = default)
{
var settings = await _siteSettingsService.GetLexwareApiSettingsAsync();
if (!settings.IsEnabled || !settings.IsConfigured)
{
return new LexwareSyncResult
{
Success = false,
Message = "Lexware API ist nicht konfiguriert oder deaktiviert."
};
}
var result = new LexwareSyncResult();
try
{
_logger.LogInformation("Starting Lexware customer sync...");
using var client = CreateHttpClient(settings);
var allContacts = new List<LexwareContact>();
// Fetch all pages of customers
var page = 0;
bool lastPage = false;
while (!lastPage)
{
cancellationToken.ThrowIfCancellationRequested();
var response = await client.GetAsync($"contacts?page={page}&size=250&customer=true", cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var pageResult = JsonSerializer.Deserialize<LexwareContactsResponse>(json);
if (pageResult?.Content != null)
{
// Only include contacts with customer role that are not archived
allContacts.AddRange(pageResult.Content.Where(c => !c.Archived));
}
lastPage = pageResult?.Last ?? true;
page++;
}
_logger.LogInformation("Fetched {Count} customer contacts from Lexware", allContacts.Count);
// Get all existing customers indexed by CustomerNumber (include inactive for reactivation)
var existingCustomers = await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.ToListAsync(cancellationToken);
var customersByNumber = existingCustomers
.Where(c => !string.IsNullOrEmpty(c.CustomerNumber))
.ToDictionary(c => c.CustomerNumber, c => c);
foreach (var contact in allContacts)
{
result.TotalProcessed++;
var customerNumber = contact.Roles?.Customer?.Number?.ToString() ?? "";
if (string.IsNullOrEmpty(customerNumber))
continue;
var firstName = contact.Person?.FirstName?.Trim() ?? "";
var lastName = contact.Person?.LastName?.Trim() ?? "";
// Skip contacts without a name
if (string.IsNullOrEmpty(firstName) && string.IsNullOrEmpty(lastName))
{
// Use company name as last name fallback
if (!string.IsNullOrEmpty(contact.Company?.Name))
{
lastName = contact.Company.Name;
}
else
{
continue;
}
}
var company = contact.Company?.Name?.Trim();
var phone = GetFirstPhone(contact.PhoneNumbers);
var email = GetFirstEmail(contact.EmailAddresses);
var notes = contact.Note?.Trim();
if (customersByNumber.TryGetValue(customerNumber, out var existing))
{
// Update existing customer
bool changed = false;
if (existing.FirstName != firstName) { existing.FirstName = firstName; changed = true; }
if (existing.LastName != lastName) { existing.LastName = lastName; changed = true; }
if (existing.Company != company) { existing.Company = company; changed = true; }
if (existing.Phone != phone) { existing.Phone = phone; changed = true; }
if (existing.Email != email) { existing.Email = email; changed = true; }
if (existing.Notes != notes) { existing.Notes = notes; changed = true; }
// Reactivate if previously deactivated
if (!existing.IsActive) { existing.IsActive = true; changed = true; }
if (changed)
{
existing.UpdatedAt = DateTime.UtcNow;
_context.Customers.Update(existing);
result.CustomersUpdated++;
}
}
else
{
// Create new customer
var newCustomer = new Customer
{
CustomerNumber = customerNumber,
FirstName = string.IsNullOrEmpty(firstName) ? "-" : firstName,
LastName = string.IsNullOrEmpty(lastName) ? "-" : lastName,
Company = company,
Phone = phone,
Email = email,
Notes = notes,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_context.Customers.Add(newCustomer);
result.CustomersCreated++;
}
}
await _context.SaveChangesAsync(cancellationToken);
// Deactivate customers not found in Lexware (Lexware is single source of truth)
var lexwareCustomerNumbers = allContacts
.Select(c => c.Roles?.Customer?.Number?.ToString())
.Where(n => !string.IsNullOrEmpty(n))
.ToHashSet();
var customersToDeactivate = await _context.Customers
.Where(c => c.IsActive && !string.IsNullOrEmpty(c.CustomerNumber) && !lexwareCustomerNumbers.Contains(c.CustomerNumber))
.ToListAsync(cancellationToken);
foreach (var customer in customersToDeactivate)
{
customer.IsActive = false;
customer.UpdatedAt = DateTime.UtcNow;
result.CustomersDeactivated++;
_logger.LogWarning("Customer {CustomerNumber} ({Name}) deactivated - not found in Lexware",
customer.CustomerNumber, customer.FullName);
}
if (customersToDeactivate.Count > 0)
await _context.SaveChangesAsync(cancellationToken);
// Update sync timestamp
settings.LastSyncAt = DateTime.UtcNow;
settings.LastSyncCount = result.TotalProcessed;
settings.LastSyncResult = $"{result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert";
await _siteSettingsService.SaveLexwareApiSettingsAsync(settings);
result.Success = true;
result.Message = $"Synchronisierung erfolgreich: {result.CustomersCreated} erstellt, {result.CustomersUpdated} aktualisiert, {result.CustomersDeactivated} deaktiviert ({result.TotalProcessed} verarbeitet)";
_logger.LogInformation("Lexware sync completed: {Message}", result.Message);
}
catch (HttpRequestException ex)
{
result.Success = false;
result.Message = $"API-Verbindungsfehler: {ex.Message}";
_logger.LogError(ex, "Lexware sync failed: HTTP error");
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Synchronisierungsfehler: {ex.Message}";
_logger.LogError(ex, "Lexware sync failed");
}
return result;
}
private HttpClient CreateHttpClient(LexwareApiSettings settings)
{
var client = _httpClientFactory.CreateClient("LexwareApi");
client.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
private static string? GetFirstPhone(LexwarePhoneNumbers? phones)
{
if (phones == null) return null;
return phones.Mobile?.FirstOrDefault()
?? phones.Business?.FirstOrDefault()
?? phones.Private?.FirstOrDefault();
}
private static string? GetFirstEmail(LexwareEmailAddresses? emails)
{
if (emails == null) return null;
return emails.Business?.FirstOrDefault()
?? emails.Private?.FirstOrDefault()
?? emails.Other?.FirstOrDefault();
}
}
/// <summary>
/// Background service that runs daily Lexware customer sync
/// </summary>
public class LexwareSyncBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<LexwareSyncBackgroundService> _logger;
public LexwareSyncBackgroundService(IServiceProvider serviceProvider, ILogger<LexwareSyncBackgroundService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Lexware sync background service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISiteSettingsService>();
var settings = await settingsService.GetLexwareApiSettingsAsync();
if (settings is { IsEnabled: true, IsConfigured: true, AutoSyncEnabled: true })
{
// Parse target time
if (TimeSpan.TryParse(settings.AutoSyncTime, out var targetTime))
{
var now = DateTime.UtcNow;
var nextRun = now.Date.Add(targetTime);
// If we've already passed the target time today, schedule for tomorrow
if (now > nextRun)
nextRun = nextRun.AddDays(1);
var delay = nextRun - now;
_logger.LogDebug("Lexware auto-sync scheduled for {NextRun} (in {Delay})", nextRun, delay);
await Task.Delay(delay, stoppingToken);
if (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Running scheduled Lexware customer sync...");
using var syncScope = _serviceProvider.CreateScope();
var syncService = syncScope.ServiceProvider.GetRequiredService<ILexwareSyncService>();
var result = await syncService.SyncCustomersAsync(stoppingToken);
_logger.LogInformation("Scheduled Lexware sync result: {Message}", result.Message);
}
}
else
{
// Invalid time format, wait and retry
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
else
{
// Not configured or disabled, check again in 15 minutes
await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Lexware sync background service");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
_logger.LogInformation("Lexware sync background service stopped");
}
}
@@ -0,0 +1,275 @@
using System.Text.RegularExpressions;
using Lagerbestand.Data;
using Lagerbestand.Exceptions;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Result of location code validation.
/// </summary>
public record LocationCodeValidationResult(
bool IsValid,
string? ErrorMessage = null,
AreaType? Area = null,
int? Row = null,
ShelfPosition? Position = null,
int? SlotNumber = null
);
/// <summary>
/// Parsed components of a location code.
/// </summary>
public record ParsedLocationCode(AreaType Area, int Row, ShelfPosition Position, int SlotNumber)
{
/// <summary>
/// Generates the 4-digit code string.
/// </summary>
public string ToCode() => $"{(int)Area}{Row}{(int)Position}{SlotNumber}";
}
/// <summary>
/// Service for generating, parsing, and validating storage location codes.
/// Location codes follow the ABCD format where:
/// - A: Gebäude (1=Werkstatt, 2=Lager)
/// - B: Reihe (Werkstatt: 1-3, Lager: 1-5)
/// - C: Seite/Ebene (1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten)
/// - D: Platz (1-9)
/// </summary>
public interface ILocationCodeService
{
/// <summary>
/// Generates a location code from component parts.
/// </summary>
/// <param name="area">Storage building</param>
/// <param name="row">Row number (Werkstatt: 1-3, Lager: 1-5)</param>
/// <param name="position">Shelf position (side/level)</param>
/// <param name="slot">Slot number (1-9)</param>
/// <returns>4-digit location code string</returns>
string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot);
/// <summary>
/// Parses a location code string into its component parts.
/// </summary>
/// <param name="code">4-digit location code</param>
/// <returns>Parsed location code components</returns>
/// <exception cref="InvalidLocationCodeException">If code format is invalid</exception>
ParsedLocationCode ParseLocationCode(string code);
/// <summary>
/// Tries to parse a location code without throwing exceptions.
/// </summary>
bool TryParseLocationCode(string code, out ParsedLocationCode? result);
/// <summary>
/// Validates a location code format.
/// </summary>
/// <param name="code">Code to validate</param>
/// <returns>Validation result with details</returns>
LocationCodeValidationResult ValidateLocationCode(string code);
/// <summary>
/// Finds the next available (existing, unoccupied) location in a given area and row.
/// </summary>
/// <param name="area">Storage building</param>
/// <param name="row">Row number</param>
/// <returns>Parsed code of next free location, or null if row is full</returns>
Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row);
/// <summary>
/// Checks if a specific location code is available (exists and not occupied).
/// </summary>
Task<bool> IsLocationAvailableAsync(string code);
/// <summary>
/// Checks if a location code already exists in the database.
/// </summary>
Task<bool> CodeExistsAsync(string code);
/// <summary>
/// Gets all occupied slots in a specific area and row.
/// </summary>
Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row);
/// <summary>
/// Gets all existing slot numbers in a specific area and row.
/// </summary>
Task<List<int>> GetExistingSlotsAsync(AreaType area, int row);
/// <summary>
/// Formats a location code for display with human-readable text.
/// </summary>
string FormatForDisplay(string code);
}
/// <inheritdoc />
public class LocationCodeService : ILocationCodeService
{
private readonly AppDbContext _context;
private readonly ILogger<LocationCodeService> _logger;
private static readonly Regex CodeRegex = new(StorageLocation.LocationCodePattern, RegexOptions.Compiled);
public LocationCodeService(AppDbContext context, ILogger<LocationCodeService> logger)
{
_context = context;
_logger = logger;
}
/// <inheritdoc />
public string GenerateLocationCode(AreaType area, int row, ShelfPosition position, int slot)
{
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
throw new ArgumentOutOfRangeException(nameof(row),
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
if (slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
throw new ArgumentOutOfRangeException(nameof(slot),
$"Platz muss zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
return $"{(int)area}{row}{(int)position}{slot}";
}
/// <inheritdoc />
public ParsedLocationCode ParseLocationCode(string code)
{
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(code, validation.ErrorMessage ?? "Ungültiges Format");
return new ParsedLocationCode(
validation.Area!.Value,
validation.Row!.Value,
validation.Position!.Value,
validation.SlotNumber!.Value
);
}
/// <inheritdoc />
public bool TryParseLocationCode(string code, out ParsedLocationCode? result)
{
result = null;
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
return false;
result = new ParsedLocationCode(
validation.Area!.Value,
validation.Row!.Value,
validation.Position!.Value,
validation.SlotNumber!.Value
);
return true;
}
/// <inheritdoc />
public LocationCodeValidationResult ValidateLocationCode(string code)
{
if (string.IsNullOrWhiteSpace(code))
return new LocationCodeValidationResult(false, "Lagerplatz-Code darf nicht leer sein.");
code = code.Trim();
if (code.Length != 4)
return new LocationCodeValidationResult(false,
$"Lagerplatz-Code muss genau 4 Zeichen lang sein (aktuell: {code.Length}).");
if (!CodeRegex.IsMatch(code))
return new LocationCodeValidationResult(false,
"Ungültiges Format. Erwartet: ABCD wobei A=Gebäude (1-2), B=Reihe, C=Seite/Ebene (1-4), D=Platz (1-9).");
// Parse area (position A)
if (!AreaTypeExtensions.TryFromCode(code[0], out var area))
return new LocationCodeValidationResult(false,
$"Ungültiges Gebäude '{code[0]}'. Erlaubt: 1 (Werkstatt), 2 (Lager).");
// Parse row (position B) - max row depends on building
if (!int.TryParse(code[1].ToString(), out var row))
return new LocationCodeValidationResult(false,
$"Ungültige Reihe '{code[1]}'.");
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
return new LocationCodeValidationResult(false,
$"Reihe {row} außerhalb des gültigen Bereichs für {area.GetDisplayName()} ({StorageLocation.MinRow}-{maxRow}).");
// Parse position (position C)
if (!ShelfPositionExtensions.TryFromCode(code[2], out var position))
return new LocationCodeValidationResult(false,
$"Ungültige Seite/Ebene '{code[2]}'. Erlaubt: 1 (links oben), 2 (links unten), 3 (rechts oben), 4 (rechts unten).");
// Parse slot (position D)
if (!int.TryParse(code[3].ToString(), out var slot) || slot < StorageLocation.MinSlot || slot > StorageLocation.MaxSlot)
return new LocationCodeValidationResult(false,
$"Ungültiger Platz '{code[3]}'. Erlaubt: {StorageLocation.MinSlot}-{StorageLocation.MaxSlot}.");
return new LocationCodeValidationResult(true, null, area, row, position, slot);
}
/// <inheritdoc />
public async Task<ParsedLocationCode?> GetNextAvailableSlotAsync(AreaType area, int row)
{
var freeLocation = await _context.StorageLocations
.AsNoTracking()
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsAvailable && !l.IsOccupied)
.OrderBy(l => l.SlotNumber)
.ThenBy(l => l.Position)
.FirstOrDefaultAsync();
if (freeLocation == null)
return null; // Row is full
return new ParsedLocationCode(freeLocation.Area, freeLocation.Row, freeLocation.Position, freeLocation.SlotNumber);
}
/// <inheritdoc />
public async Task<bool> IsLocationAvailableAsync(string code)
{
var validation = ValidateLocationCode(code);
if (!validation.IsValid)
return false;
var location = await _context.StorageLocations
.FirstOrDefaultAsync(l => l.Code == code && l.IsActive);
return location != null && !location.IsOccupied && location.IsAvailable;
}
/// <inheritdoc />
public async Task<bool> CodeExistsAsync(string code)
{
return await _context.StorageLocations
.AnyAsync(l => l.Code == code);
}
/// <inheritdoc />
public async Task<List<int>> GetOccupiedSlotsAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row && l.IsActive && l.IsOccupied)
.Select(l => l.SlotNumber)
.OrderBy(s => s)
.ToListAsync();
}
/// <inheritdoc />
public async Task<List<int>> GetExistingSlotsAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row && l.IsActive)
.Select(l => l.SlotNumber)
.OrderBy(s => s)
.ToListAsync();
}
/// <inheritdoc />
public string FormatForDisplay(string code)
{
if (!TryParseLocationCode(code, out var parsed) || parsed == null)
return code;
return $"{parsed.Area.GetDisplayName()}, Reihe {parsed.Row}, {parsed.Position.GetDisplayName()}, Platz {parsed.SlotNumber}";
}
}
@@ -0,0 +1,452 @@
using System.Text.Json;
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public interface ISiteSettingsService
{
Task<PromoBannerSettings> GetPromoBannerSettingsAsync();
Task SavePromoBannerSettingsAsync(PromoBannerSettings settings);
Task<LexwareApiSettings> GetLexwareApiSettingsAsync();
Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings);
Task<TwoFactorSettings> GetTwoFactorSettingsAsync();
Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings);
Task<SmtpSettings> GetSmtpSettingsAsync();
Task SaveSmtpSettingsAsync(SmtpSettings settings);
Task<SessionSettings> GetSessionSettingsAsync();
Task SaveSessionSettingsAsync(SessionSettings settings);
/// <summary>
/// Gets a raw setting value by key
/// </summary>
Task<string?> GetValueAsync(string key);
/// <summary>
/// Sets a raw setting value by key
/// </summary>
Task SetValueAsync(string key, string value);
}
public class SiteSettingsService : ISiteSettingsService
{
private const string PromoBannerKey = "PromoBanner";
private const string LexwareApiKey = "LexwareApi";
private const string TwoFactorKey = "TwoFactor";
private const string SmtpKey = "Smtp";
private const string SessionKey = "Session";
private readonly AppDbContext _context;
private readonly ILogger<SiteSettingsService> _logger;
public SiteSettingsService(AppDbContext context, ILogger<SiteSettingsService> logger)
{
_context = context;
_logger = logger;
}
public async Task<PromoBannerSettings> GetPromoBannerSettingsAsync()
{
try
{
// Quick timeout to prevent blocking app startup
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey, cts.Token);
if (setting == null)
{
return new PromoBannerSettings();
}
var settings = JsonSerializer.Deserialize<PromoBannerSettings>(setting.Value);
return settings ?? new PromoBannerSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Banner settings query timed out, returning default");
return new PromoBannerSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
// Table doesn't exist yet - this is normal during first startup
_logger.LogDebug("SiteSettings table not found, returning default settings");
return new PromoBannerSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load promo banner settings");
return new PromoBannerSettings();
}
}
public async Task SavePromoBannerSettingsAsync(PromoBannerSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == PromoBannerKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = PromoBannerKey,
Value = json,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Promo banner settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save promo banner settings");
throw;
}
}
public async Task<LexwareApiSettings> GetLexwareApiSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey, cts.Token);
if (setting == null)
return new LexwareApiSettings();
var settings = JsonSerializer.Deserialize<LexwareApiSettings>(setting.Value);
return settings ?? new LexwareApiSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Lexware settings query timed out, returning default");
return new LexwareApiSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
_logger.LogDebug("SiteSettings table not found, returning default Lexware settings");
return new LexwareApiSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Lexware API settings");
return new LexwareApiSettings();
}
}
public async Task SaveLexwareApiSettingsAsync(LexwareApiSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == LexwareApiKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = LexwareApiKey,
Value = json,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Lexware API settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Lexware API settings");
throw;
}
}
public async Task<TwoFactorSettings> GetTwoFactorSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey, cts.Token);
if (setting == null)
return new TwoFactorSettings();
return JsonSerializer.Deserialize<TwoFactorSettings>(setting.Value) ?? new TwoFactorSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("TwoFactor settings query timed out, returning default");
return new TwoFactorSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new TwoFactorSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load TwoFactor settings");
return new TwoFactorSettings();
}
}
public async Task SaveTwoFactorSettingsAsync(TwoFactorSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == TwoFactorKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = TwoFactorKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("TwoFactor settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save TwoFactor settings");
throw;
}
}
public async Task<SmtpSettings> GetSmtpSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SmtpKey, cts.Token);
if (setting == null)
return new SmtpSettings();
return JsonSerializer.Deserialize<SmtpSettings>(setting.Value) ?? new SmtpSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("SMTP settings query timed out, returning default");
return new SmtpSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new SmtpSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load SMTP settings");
return new SmtpSettings();
}
}
public async Task SaveSmtpSettingsAsync(SmtpSettings settings)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == SmtpKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = SmtpKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("SMTP settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save SMTP settings");
throw;
}
}
public async Task<SessionSettings> GetSessionSettingsAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SessionKey, cts.Token);
if (setting == null)
return new SessionSettings();
return JsonSerializer.Deserialize<SessionSettings>(setting.Value) ?? new SessionSettings();
}
catch (OperationCanceledException)
{
_logger.LogDebug("Session settings query timed out, returning default");
return new SessionSettings();
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
return new SessionSettings();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load Session settings");
return new SessionSettings();
}
}
public async Task SaveSessionSettingsAsync(SessionSettings settings)
{
try
{
// Clamp to safe ranges before persisting
settings.IdleTimeoutMinutes = settings.GetSafeIdleTimeoutMinutes();
settings.CircuitRetentionHours = settings.GetSafeCircuitRetentionHours();
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == SessionKey);
var json = JsonSerializer.Serialize(settings);
if (existing != null)
{
existing.Value = json;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
_context.SiteSettings.Add(new SiteSettings
{
Key = SessionKey,
Value = json,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
_logger.LogInformation("Session settings saved successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save Session settings");
throw;
}
}
public async Task<string?> GetValueAsync(string key)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var setting = await _context.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == key, cts.Token);
return setting?.Value;
}
catch (OperationCanceledException)
{
_logger.LogDebug("Settings query for '{Key}' timed out", key);
return null;
}
catch (MySqlConnector.MySqlException ex) when (ex.Number == 1146)
{
_logger.LogDebug("SiteSettings table not found for key '{Key}'", key);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load setting '{Key}'", key);
return null;
}
}
public async Task SetValueAsync(string key, string value)
{
try
{
var existing = await _context.SiteSettings
.FirstOrDefaultAsync(s => s.Key == key);
if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
_context.SiteSettings.Update(existing);
}
else
{
var newSetting = new SiteSettings
{
Key = key,
Value = value,
CreatedAt = DateTime.UtcNow
};
_context.SiteSettings.Add(newSetting);
}
await _context.SaveChangesAsync();
_logger.LogInformation("Setting '{Key}' saved successfully", key);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save setting '{Key}'", key);
throw;
}
}
}
@@ -0,0 +1,447 @@
using Lagerbestand.Data;
using Lagerbestand.Exceptions;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Statistics about storage location occupancy.
/// </summary>
public record OccupancyStatistics(
int TotalLocations,
int OccupiedLocations,
int AvailableLocations,
int BlockedLocations,
int TotalByLager,
int OccupiedByLager,
int TotalByWerkstatt,
int OccupiedByWerkstatt,
Dictionary<int, int> LocationsByRow,
Dictionary<int, int> OccupiedByRow
);
public interface IStorageLocationService
{
Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false);
Task<StorageLocation?> GetByIdAsync(Guid id);
Task<StorageLocation?> GetByCodeAsync(string code);
Task<List<StorageLocation>> GetAvailableAsync();
Task<List<StorageLocation>> GetByAreaAsync(AreaType area);
Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row);
Task<StorageLocation> CreateAsync(StorageLocation location);
Task<StorageLocation> UpdateAsync(StorageLocation location);
Task<bool> DeleteAsync(Guid id);
Task<List<AreaType>> GetAreasAsync();
Task<List<int>> GetRowsAsync(AreaType area);
Task<bool> IsLocationAvailableAsync(Guid locationId);
Task<int> GetOccupancyAsync(Guid locationId);
Task AssignTireStackAsync(string locationCode, Guid tireStackId);
Task ClearLocationAsync(string locationCode);
Task MoveStackAsync(string fromCode, string toCode);
Task<OccupancyStatistics> GetOccupancyStatisticsAsync();
Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot);
/// <summary>
/// Ensures all default storage locations exist (Werkstatt: 3 rows, Lager: 5 rows,
/// each row 9 slots × 4 positions = 288 total). Creates missing, returns number created.
/// </summary>
Task<int> SeedDefaultLocationsAsync();
}
public class StorageLocationService : IStorageLocationService
{
private readonly AppDbContext _context;
private readonly ILocationCodeService _locationCodeService;
private readonly ILogger<StorageLocationService> _logger;
public StorageLocationService(
AppDbContext context,
ILocationCodeService locationCodeService,
ILogger<StorageLocationService> logger)
{
_context = context;
_locationCodeService = locationCodeService;
_logger = logger;
}
public async Task<List<StorageLocation>> GetAllAsync(bool includeInactive = false)
{
var query = _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.AsQueryable();
if (!includeInactive)
query = query.Where(s => s.IsActive);
return await query
.OrderBy(s => s.Area)
.ThenBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<StorageLocation?> GetByIdAsync(Guid id)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.FirstOrDefaultAsync(s => s.Id == id);
}
public async Task<StorageLocation?> GetByCodeAsync(string code)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.CatalogItem)
.FirstOrDefaultAsync(s => s.Code == code);
}
public async Task<List<StorageLocation>> GetAvailableAsync()
{
return await _context.StorageLocations
.AsNoTracking()
.Where(s => s.IsActive && s.IsAvailable && !s.IsOccupied)
.OrderBy(s => s.Area)
.ThenBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<List<StorageLocation>> GetByAreaAsync(AreaType area)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Where(s => s.IsActive && s.Area == area)
.OrderBy(s => s.Row)
.ThenBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<List<StorageLocation>> GetByRowAsync(AreaType area, int row)
{
return await _context.StorageLocations
.Include(s => s.OccupiedTireStack)
.ThenInclude(ts => ts!.Customer)
.Where(s => s.IsActive && s.Area == area && s.Row == row)
.OrderBy(s => s.SlotNumber)
.ToListAsync();
}
public async Task<StorageLocation> CreateAsync(StorageLocation location)
{
// Ensure code is generated from components
location.GenerateCode();
// Validate the generated code
var validation = _locationCodeService.ValidateLocationCode(location.Code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
// Check for duplicate code
if (await _locationCodeService.CodeExistsAsync(location.Code))
throw new LocationCodeExistsException(location.Code);
location.CreatedAt = DateTime.UtcNow;
_context.StorageLocations.Add(location);
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location created: {Code} ({Display})",
location.Code, location.DisplayName);
return location;
}
public async Task<StorageLocation> UpdateAsync(StorageLocation location)
{
// Regenerate code from components
location.GenerateCode();
// Validate the code
var validation = _locationCodeService.ValidateLocationCode(location.Code);
if (!validation.IsValid)
throw new InvalidLocationCodeException(location.Code, validation.ErrorMessage!);
// Check for duplicate code (excluding this location)
var existingWithCode = await _context.StorageLocations
.FirstOrDefaultAsync(l => l.Code == location.Code && l.Id != location.Id);
if (existingWithCode != null)
throw new LocationCodeExistsException(location.Code);
location.UpdatedAt = DateTime.UtcNow;
_context.StorageLocations.Update(location);
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location updated: {Code}", location.Code);
return location;
}
public async Task<bool> DeleteAsync(Guid id)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == id);
if (location == null)
return false;
// Cannot delete occupied location
if (location.IsOccupied)
{
_logger.LogWarning("Cannot delete storage location {Code} - is occupied", location.Code);
return false;
}
// Soft delete
location.IsActive = false;
location.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Storage location deactivated: {Code}", location.Code);
return true;
}
public async Task<List<AreaType>> GetAreasAsync()
{
var areas = await _context.StorageLocations
.Where(s => s.IsActive)
.Select(s => s.Area)
.Distinct()
.ToListAsync();
return areas.OrderBy(a => (int)a).ToList();
}
public async Task<List<int>> GetRowsAsync(AreaType area)
{
return await _context.StorageLocations
.Where(s => s.IsActive && s.Area == area)
.Select(s => s.Row)
.Distinct()
.OrderBy(r => r)
.ToListAsync();
}
public async Task<bool> IsLocationAvailableAsync(Guid locationId)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == locationId);
if (location == null || !location.IsActive || !location.IsAvailable)
return false;
return !location.IsOccupied;
}
public async Task<int> GetOccupancyAsync(Guid locationId)
{
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == locationId);
return location?.IsOccupied == true ? 1 : 0;
}
public async Task AssignTireStackAsync(string locationCode, Guid tireStackId)
{
var location = await GetByCodeAsync(locationCode)
?? throw new LocationNotFoundException(locationCode);
if (location.IsOccupied)
throw new LocationOccupiedException(locationCode, location.TireStackId!.Value);
if (!location.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {locationCode} ist gesperrt.");
location.AssignTireStack(tireStackId);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TireStackId} assigned to location {Code}",
tireStackId, locationCode);
}
public async Task ClearLocationAsync(string locationCode)
{
var location = await GetByCodeAsync(locationCode)
?? throw new LocationNotFoundException(locationCode);
var previousTireStackId = location.TireStackId;
location.ClearLocation();
await _context.SaveChangesAsync();
_logger.LogInformation("Location {Code} cleared (previous tire stack: {TireStackId})",
locationCode, previousTireStackId);
}
public async Task MoveStackAsync(string fromCode, string toCode)
{
var fromLocation = await GetByCodeAsync(fromCode)
?? throw new LocationNotFoundException(fromCode);
var toLocation = await GetByCodeAsync(toCode)
?? throw new LocationNotFoundException(toCode);
if (!fromLocation.IsOccupied)
throw new InvalidOperationException($"Lagerplatz {fromCode} ist nicht belegt.");
if (toLocation.IsOccupied)
throw new LocationOccupiedException(toCode, toLocation.TireStackId!.Value);
if (!toLocation.IsAvailable)
throw new InvalidOperationException($"Lagerplatz {toCode} ist gesperrt.");
var tireStackId = fromLocation.TireStackId!.Value;
// Update the tire stack's storage location reference
var tireStack = await _context.TireStacks.FindAsync(tireStackId);
if (tireStack != null)
{
tireStack.StorageLocationId = toLocation.Id;
tireStack.UpdatedAt = DateTime.UtcNow;
}
// Clear source and assign to destination
fromLocation.ClearLocation();
toLocation.AssignTireStack(tireStackId);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TireStackId} moved from {FromCode} to {ToCode}",
tireStackId, fromCode, toCode);
}
public async Task<OccupancyStatistics> GetOccupancyStatisticsAsync()
{
var locations = await _context.StorageLocations
.Where(l => l.IsActive)
.ToListAsync();
var locationsByRow = locations
.GroupBy(l => l.Row)
.ToDictionary(g => g.Key, g => g.Count());
var occupiedByRow = locations
.Where(l => l.IsOccupied)
.GroupBy(l => l.Row)
.ToDictionary(g => g.Key, g => g.Count());
return new OccupancyStatistics(
TotalLocations: locations.Count,
OccupiedLocations: locations.Count(l => l.IsOccupied),
AvailableLocations: locations.Count(l => !l.IsOccupied && l.IsAvailable),
BlockedLocations: locations.Count(l => !l.IsAvailable),
TotalByLager: locations.Count(l => l.Area == AreaType.Lager),
OccupiedByLager: locations.Count(l => l.Area == AreaType.Lager && l.IsOccupied),
TotalByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt),
OccupiedByWerkstatt: locations.Count(l => l.Area == AreaType.Werkstatt && l.IsOccupied),
LocationsByRow: locationsByRow,
OccupiedByRow: occupiedByRow
);
}
public async Task<List<StorageLocation>> CreateBatchAsync(AreaType area, int row, int fromSlot, int toSlot)
{
var maxRow = area.GetMaxRow();
if (row < StorageLocation.MinRow || row > maxRow)
throw new ArgumentOutOfRangeException(nameof(row),
$"Reihe muss für {area.GetDisplayName()} zwischen {StorageLocation.MinRow} und {maxRow} liegen.");
if (fromSlot < StorageLocation.MinSlot || toSlot > StorageLocation.MaxSlot || fromSlot > toSlot)
throw new ArgumentOutOfRangeException(nameof(fromSlot),
$"Plätze müssen zwischen {StorageLocation.MinSlot} und {StorageLocation.MaxSlot} liegen.");
var existingCodes = await _context.StorageLocations
.Where(l => l.Area == area && l.Row == row)
.Select(l => l.Code)
.ToListAsync();
var existingSet = existingCodes.ToHashSet();
var createdLocations = new List<StorageLocation>();
for (int slot = fromSlot; slot <= toSlot; slot++)
{
foreach (var position in ShelfPositionExtensions.All)
{
var location = new StorageLocation
{
Area = area,
Row = row,
Position = position,
SlotNumber = slot
};
location.GenerateCode();
if (existingSet.Contains(location.Code))
{
_logger.LogDebug("Skipping existing location: {Code}", location.Code);
continue;
}
_context.StorageLocations.Add(location);
createdLocations.Add(location);
}
}
if (createdLocations.Count > 0)
{
await _context.SaveChangesAsync();
_logger.LogInformation("Created {Count} storage locations in {Area} Row {Row}",
createdLocations.Count, area.GetDisplayName(), row);
}
return createdLocations;
}
public async Task<int> SeedDefaultLocationsAsync()
{
var existingCodes = (await _context.StorageLocations
.Select(l => l.Code)
.ToListAsync()).ToHashSet();
var created = 0;
foreach (var area in new[] { AreaType.Werkstatt, AreaType.Lager })
{
for (int row = StorageLocation.MinRow; row <= area.GetMaxRow(); row++)
{
foreach (var position in ShelfPositionExtensions.All)
{
for (int slot = StorageLocation.MinSlot; slot <= StorageLocation.MaxSlot; slot++)
{
var location = new StorageLocation
{
Area = area,
Row = row,
Position = position,
SlotNumber = slot
};
location.GenerateCode();
if (existingCodes.Contains(location.Code))
continue;
_context.StorageLocations.Add(location);
created++;
}
}
}
}
if (created > 0)
{
await _context.SaveChangesAsync();
_logger.LogInformation("Seeded {Count} default storage locations", created);
}
return created;
}
}
+504
View File
@@ -0,0 +1,504 @@
using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
/// <summary>
/// Service interface for managing tire stacks
/// </summary>
public interface ITireStackService
{
Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null);
Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory);
Task<TireStack?> GetByIdAsync(Guid id);
Task<TireStack?> GetByTicketNumberAsync(string ticketNumber);
Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null);
Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null);
Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId);
Task<List<TireStack>> SearchAsync(string searchTerm);
Task<TireStack> CreateAsync(TireStack tireStack);
Task<TireStack> UpdateAsync(TireStack tireStack);
Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null);
Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null);
Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null);
Task<bool> DeleteAsync(Guid id);
Task<string> GenerateTicketNumberAsync(bool isInventory = false);
Task<StorageStatistics> GetStatisticsAsync();
Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14);
}
/// <summary>
/// Statistics for the tire storage system
/// </summary>
public class StorageStatistics
{
/// <summary>
/// Total number of stacks currently stored
/// </summary>
public int TotalStacksStored { get; set; }
/// <summary>
/// Total number of individual tires stored (stacks * 4)
/// </summary>
public int TotalTiresStored => TotalStacksStored * TireStack.TiresPerStack;
/// <summary>
/// Total stacks picked up (historical)
/// </summary>
public int TotalStacksPickedUp { get; set; }
/// <summary>
/// Number of active customers
/// </summary>
public int TotalCustomers { get; set; }
/// <summary>
/// Number of available storage locations
/// </summary>
public int AvailableLocations { get; set; }
/// <summary>
/// Number of occupied storage locations
/// </summary>
public int OccupiedLocations { get; set; }
/// <summary>
/// Summer tire stacks stored
/// </summary>
public int SummerStacksStored { get; set; }
/// <summary>
/// Winter tire stacks stored
/// </summary>
public int WinterStacksStored { get; set; }
/// <summary>
/// All-season tire stacks stored
/// </summary>
public int AllSeasonStacksStored { get; set; }
/// <summary>
/// Inventory stacks (shop stock)
/// </summary>
public int InventoryStackCount { get; set; }
/// <summary>
/// Customer tire stacks
/// </summary>
public int CustomerStackCount { get; set; }
}
/// <summary>
/// Service implementation for managing tire stacks
/// </summary>
public class TireStackService : ITireStackService
{
private readonly AppDbContext _context;
private readonly ILogger<TireStackService> _logger;
public TireStackService(AppDbContext context, ILogger<TireStackService> logger)
{
_context = context;
_logger = logger;
}
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status = null)
{
return await GetAllAsync(status, null);
}
public async Task<List<TireStack>> GetAllAsync(TireStackStatus? status, bool? isInventory)
{
var query = _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
if (isInventory.HasValue)
{
if (isInventory.Value)
query = query.Where(ts => ts.CustomerId == null);
else
query = query.Where(ts => ts.CustomerId != null);
}
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<TireStack?> GetByIdAsync(Guid id)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.FirstOrDefaultAsync(ts => ts.Id == id && ts.IsActive);
}
public async Task<TireStack?> GetByTicketNumberAsync(string ticketNumber)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.FirstOrDefaultAsync(ts => ts.TicketNumber == ticketNumber && ts.IsActive);
}
public async Task<List<TireStack>> GetByCustomerAsync(Guid customerId, TireStackStatus? status = null)
{
var query = _context.TireStacks
.Include(ts => ts.StorageLocation)
.Include(ts => ts.Vehicle)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.CustomerId == customerId && ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> GetByCategoryAsync(Guid catalogItemId, TireStackStatus? status = null)
{
var query = _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.CatalogItemId == catalogItemId && ts.IsActive);
if (status.HasValue)
query = query.Where(ts => ts.Status == status.Value);
return await query
.OrderByDescending(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> GetByStorageLocationAsync(Guid locationId)
{
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.StorageLocationId == locationId && ts.Status == TireStackStatus.Stored && ts.IsActive)
.OrderBy(ts => ts.StoredAt)
.ToListAsync();
}
public async Task<List<TireStack>> SearchAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllAsync(TireStackStatus.Stored);
var term = searchTerm.Trim().ToLower();
return await _context.TireStacks
.AsNoTracking()
.Include(ts => ts.Customer)
.Include(ts => ts.Vehicle)
.Include(ts => ts.StorageLocation)
.Include(ts => ts.CatalogItem)
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && (
ts.TicketNumber.ToLower().Contains(term) ||
(ts.TireBrand != null && ts.TireBrand.ToLower().Contains(term)) ||
(ts.TireSize != null && ts.TireSize.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.FirstName != null && ts.Customer.FirstName.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.LastName.ToLower().Contains(term)) ||
(ts.Customer != null && ts.Customer.CustomerNumber.ToLower().Contains(term)) ||
(ts.Vehicle != null && ts.Vehicle.Make.ToLower().Contains(term)) ||
(ts.Vehicle != null && ts.Vehicle.Model != null && ts.Vehicle.Model.ToLower().Contains(term)) ||
(ts.StorageLocation != null && ts.StorageLocation.Code.ToLower().Contains(term)) ||
(ts.CatalogItem != null && ts.CatalogItem.Name.ToLower().Contains(term))
))
.OrderByDescending(ts => ts.StoredAt)
.Take(50)
.ToListAsync();
}
public async Task<TireStack> CreateAsync(TireStack tireStack)
{
var isInventory = tireStack.CustomerId == null;
if (string.IsNullOrEmpty(tireStack.TicketNumber))
tireStack.TicketNumber = await GenerateTicketNumberAsync(isInventory);
tireStack.CreatedAt = DateTime.UtcNow;
tireStack.StoredAt = DateTime.UtcNow;
_context.TireStacks.Add(tireStack);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack created: {TicketNumber}, IsInventory: {IsInventory}",
tireStack.TicketNumber, isInventory);
return tireStack;
}
public async Task<TireStack> UpdateAsync(TireStack tireStack)
{
tireStack.UpdatedAt = DateTime.UtcNow;
_context.TireStacks.Update(tireStack);
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack updated: {TicketNumber}", tireStack.TicketNumber);
return tireStack;
}
public async Task<TireStack> StoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
{
// Verify storage location is available (force fresh values in case a
// stale tracked instance exists in this long-lived scoped context)
var location = await _context.StorageLocations
.FirstOrDefaultAsync(s => s.Id == storageLocationId);
if (location == null)
throw new InvalidOperationException("Lagerplatz nicht gefunden.");
await _context.Entry(location).ReloadAsync();
if (!location.IsActive || !location.IsAvailable)
throw new InvalidOperationException("Lagerplatz ist nicht verfügbar.");
// AssignTireStack handles the IsOccupied check and sets TireStackId/IsOccupied/OccupiedSince
location.AssignTireStack(tireStack.Id);
tireStack.StorageLocationId = storageLocationId;
tireStack.Status = TireStackStatus.Stored;
tireStack.StoredAt = DateTime.UtcNow;
tireStack.StoredByUserId = storedByUserId;
tireStack.UpdatedAt = DateTime.UtcNow;
_context.TireStacks.Update(tireStack);
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException($"Lagerplatz {location.Code} wurde soeben anderweitig belegt.");
}
_logger.LogInformation("Tire stack {TicketNumber} stored at location {LocationCode}",
tireStack.TicketNumber, location.Code);
return tireStack;
}
public async Task<TireStack> PickupAsync(Guid tireStackId, Guid? pickedUpByUserId = null)
{
var tireStack = await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.FirstOrDefaultAsync(ts => ts.Id == tireStackId);
if (tireStack == null)
throw new InvalidOperationException("Reifenstapel nicht gefunden.");
if (tireStack.Status != TireStackStatus.Stored && tireStack.Status != TireStackStatus.Reserved)
throw new InvalidOperationException("Reifenstapel ist nicht eingelagert.");
// Clear the storage location (both sides)
if (tireStack.StorageLocation != null)
tireStack.StorageLocation.ClearLocation();
tireStack.Status = TireStackStatus.PickedUp;
tireStack.PickedUpAt = DateTime.UtcNow;
tireStack.PickedUpByUserId = pickedUpByUserId;
tireStack.StorageLocationId = null;
tireStack.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack {TicketNumber} picked up", tireStack.TicketNumber);
return tireStack;
}
public async Task<TireStack> CreateAndStoreAsync(TireStack tireStack, Guid storageLocationId, Guid? storedByUserId = null)
{
// MySqlRetryingExecutionStrategy requires user transactions to run
// inside the execution strategy as a single retriable unit.
var hadTicketNumber = !string.IsNullOrEmpty(tireStack.TicketNumber);
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var created = await CreateAsync(tireStack);
var stored = await StoreAsync(created, storageLocationId, storedByUserId);
await transaction.CommitAsync();
return stored;
}
catch
{
await transaction.RollbackAsync();
// Detach the failed entities so a retry (by the strategy or
// the user) does not re-insert stale Added/Modified state.
_context.ChangeTracker.Clear();
// Regenerate the ticket number on retry if we created it here
// (avoids permanent unique-index collisions).
if (!hadTicketNumber)
tireStack.TicketNumber = string.Empty;
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Der Lagerplatz wurde soeben anderweitig belegt. Bitte anderen Platz wählen.");
}
}
public async Task<List<TireStack>> PickupBatchAsync(IEnumerable<Guid> tireStackIds, Guid? pickedUpByUserId = null)
{
var ids = tireStackIds.Distinct().ToList();
if (ids.Count == 0)
return new List<TireStack>();
var strategy = _context.Database.CreateExecutionStrategy();
try
{
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
var pickedUp = new List<TireStack>(ids.Count);
foreach (var id in ids)
{
pickedUp.Add(await PickupAsync(id, pickedUpByUserId));
}
await transaction.CommitAsync();
return pickedUp;
}
catch
{
await transaction.RollbackAsync();
_context.ChangeTracker.Clear();
throw;
}
});
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Ein Lagerplatz wurde zwischenzeitlich geändert. Bitte erneut versuchen.");
}
}
public async Task<bool> DeleteAsync(Guid id)
{
var tireStack = await _context.TireStacks.FindAsync(id);
if (tireStack == null)
return false;
// Block deletion of stored or reserved tire stacks
if (tireStack.Status == TireStackStatus.Stored || tireStack.Status == TireStackStatus.Reserved)
{
_logger.LogWarning("Cannot delete {Status} tire stack {TicketNumber}", tireStack.Status, tireStack.TicketNumber);
return false;
}
// Soft delete
tireStack.IsActive = false;
tireStack.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Tire stack soft-deleted: {TicketNumber}", tireStack.TicketNumber);
return true;
}
public async Task<string> GenerateTicketNumberAsync(bool isInventory = false)
{
var year = DateTime.UtcNow.Year;
var prefix = isInventory ? $"INV-{year}-" : $"TS-{year}-";
var lastNumber = await _context.TireStacks
.Where(ts => ts.TicketNumber.StartsWith(prefix))
.OrderByDescending(ts => ts.TicketNumber)
.Select(ts => ts.TicketNumber)
.FirstOrDefaultAsync();
var nextNumber = 1;
if (lastNumber != null)
{
var numPart = lastNumber.Replace(prefix, "");
if (int.TryParse(numPart, out var lastNum))
nextNumber = lastNum + 1;
}
return $"{prefix}{nextNumber:D5}";
}
public async Task<StorageStatistics> GetStatisticsAsync()
{
var storedStacks = await _context.TireStacks
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored)
.GroupBy(ts => ts.Season)
.Select(g => new { Season = g.Key, Count = g.Count() })
.ToListAsync();
var totalLocations = await _context.StorageLocations
.Where(s => s.IsActive)
.CountAsync();
var occupiedLocations = await _context.TireStacks
.Where(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.StorageLocationId != null)
.Select(ts => ts.StorageLocationId)
.Distinct()
.CountAsync();
var inventoryCount = await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId == null);
var customerStackCount = await _context.TireStacks
.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.Stored && ts.CustomerId != null);
return new StorageStatistics
{
TotalStacksStored = storedStacks.Sum(x => x.Count),
TotalStacksPickedUp = await _context.TireStacks.CountAsync(ts => ts.IsActive && ts.Status == TireStackStatus.PickedUp),
TotalCustomers = await _context.Customers.CountAsync(c => c.IsActive),
AvailableLocations = totalLocations - occupiedLocations,
OccupiedLocations = occupiedLocations,
SummerStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Sommer)?.Count ?? 0,
WinterStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Winter)?.Count ?? 0,
AllSeasonStacksStored = storedStacks.FirstOrDefault(x => x.Season == TireSeason.Allrad)?.Count ?? 0,
InventoryStackCount = inventoryCount,
CustomerStackCount = customerStackCount
};
}
public async Task<List<TireStack>> GetExpiringPickupsAsync(int daysAhead = 14)
{
var cutoffDate = DateTime.UtcNow.AddDays(daysAhead);
return await _context.TireStacks
.Include(ts => ts.Customer)
.Include(ts => ts.StorageLocation)
.Where(ts => ts.IsActive &&
ts.Status == TireStackStatus.Stored &&
ts.CustomerId != null &&
ts.ExpectedPickupDate != null &&
ts.ExpectedPickupDate <= cutoffDate)
.OrderBy(ts => ts.ExpectedPickupDate)
.ToListAsync();
}
}
+243
View File
@@ -0,0 +1,243 @@
using System.Net;
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text;
using Lagerbestand.Models;
using QRCoder;
namespace Lagerbestand.Services;
public interface ITwoFactorService
{
/// <summary>
/// Generates a new random Base32-encoded TOTP secret key.
/// </summary>
string GenerateSecretKey();
/// <summary>
/// Generates the otpauth:// URI for authenticator app setup.
/// </summary>
string GenerateOtpAuthUri(string secret, string username, string issuer);
/// <summary>
/// Validates a 6-digit TOTP code against a secret (±1 time step tolerance).
/// </summary>
bool ValidateTotpCode(string secret, string code);
/// <summary>
/// Generates a random 6-digit email verification code.
/// </summary>
string GenerateEmailCode();
/// <summary>
/// Sends a 2FA code via email using configured SMTP settings.
/// </summary>
Task<bool> SendEmailCodeAsync(string toEmail, string code, string username);
/// <summary>
/// Generates a QR code as a base64 PNG data URL from an otpauth URI.
/// </summary>
string GenerateQrCodeDataUrl(string otpAuthUri);
}
public class TwoFactorService : ITwoFactorService
{
private const int SecretKeyLength = 20; // 160 bits
private const int TotpCodeLength = 6;
private const int TotpTimeStepSeconds = 30;
private const int TotpTimeTolerance = 1; // ±1 step (30 seconds each side)
private static readonly char[] Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray();
private readonly ISiteSettingsService _siteSettingsService;
private readonly ILogger<TwoFactorService> _logger;
public TwoFactorService(ISiteSettingsService siteSettingsService, ILogger<TwoFactorService> logger)
{
_siteSettingsService = siteSettingsService;
_logger = logger;
}
public string GenerateSecretKey()
{
var secretBytes = RandomNumberGenerator.GetBytes(SecretKeyLength);
return Base32Encode(secretBytes);
}
public string GenerateOtpAuthUri(string secret, string username, string issuer)
{
var encodedIssuer = Uri.EscapeDataString(issuer);
var encodedUsername = Uri.EscapeDataString(username);
return $"otpauth://totp/{encodedIssuer}:{encodedUsername}?secret={secret}&issuer={encodedIssuer}&digits={TotpCodeLength}&period={TotpTimeStepSeconds}";
}
public bool ValidateTotpCode(string secret, string code)
{
if (string.IsNullOrWhiteSpace(code) || code.Length != TotpCodeLength)
return false;
var secretBytes = Base32Decode(secret);
var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// Check current time step and ±tolerance steps
for (int i = -TotpTimeTolerance; i <= TotpTimeTolerance; i++)
{
var timeStep = (unixTimestamp / TotpTimeStepSeconds) + i;
var generatedCode = ComputeTotp(secretBytes, timeStep);
if (CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(generatedCode),
Encoding.UTF8.GetBytes(code)))
{
return true;
}
}
return false;
}
public string GenerateEmailCode()
{
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
}
public async Task<bool> SendEmailCodeAsync(string toEmail, string code, string username)
{
try
{
var smtpSettings = await _siteSettingsService.GetSmtpSettingsAsync();
if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured)
{
_logger.LogWarning("SMTP is not configured. Cannot send 2FA email to {Email}", toEmail);
return false;
}
using var client = new SmtpClient(smtpSettings.Host, smtpSettings.Port)
{
Credentials = new NetworkCredential(smtpSettings.Username, smtpSettings.Password),
EnableSsl = smtpSettings.UseSsl,
Timeout = 15_000
};
using var message = new MailMessage
{
From = new MailAddress(smtpSettings.FromEmail, smtpSettings.FromName),
Subject = $"Ihr Anmeldecode – {code}",
IsBodyHtml = true,
Body = $"""
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="color: #dc2626;">Reifen Späth – Anmeldecode</h2>
<p>Hallo <strong>{username}</strong>,</p>
<p>Ihr Einmal-Code für die Anmeldung lautet:</p>
<div style="background: #f3f4f6; border-radius: 8px; padding: 16px; text-align: center; margin: 16px 0;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 6px; color: #111;">{code}</span>
</div>
<p style="color: #666; font-size: 14px;">Dieser Code ist 5 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
</div>
"""
};
message.To.Add(toEmail);
await client.SendMailAsync(message);
_logger.LogInformation("2FA email code sent to {Email} for user {Username}", toEmail, username);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send 2FA email to {Email}", toEmail);
return false;
}
}
public string GenerateQrCodeDataUrl(string otpAuthUri)
{
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(otpAuthUri, QRCodeGenerator.ECCLevel.M);
using var qrCode = new PngByteQRCode(qrCodeData);
var pngBytes = qrCode.GetGraphic(8, new byte[] { 30, 30, 32 }, new byte[] { 255, 255, 255 });
return $"data:image/png;base64,{Convert.ToBase64String(pngBytes)}";
}
#region TOTP Internals
private static string ComputeTotp(byte[] secret, long timeStep)
{
var counterBytes = BitConverter.GetBytes(timeStep);
if (BitConverter.IsLittleEndian)
Array.Reverse(counterBytes);
var hash = HMACSHA1.HashData(secret, counterBytes);
var offset = hash[^1] & 0x0F;
var code = ((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
return (code % 1_000_000).ToString("D6");
}
#endregion
#region Base32
private static string Base32Encode(byte[] data)
{
var sb = new StringBuilder((data.Length * 8 + 4) / 5);
int buffer = data[0];
int next = 1;
int bitsLeft = 8;
while (bitsLeft > 0 || next < data.Length)
{
if (bitsLeft < 5)
{
if (next < data.Length)
{
buffer <<= 8;
buffer |= data[next++] & 0xFF;
bitsLeft += 8;
}
else
{
int pad = 5 - bitsLeft;
buffer <<= pad;
bitsLeft += pad;
}
}
int index = 0x1F & (buffer >> (bitsLeft - 5));
bitsLeft -= 5;
sb.Append(Base32Chars[index]);
}
return sb.ToString();
}
private static byte[] Base32Decode(string base32)
{
var cleanInput = base32.TrimEnd('=').ToUpperInvariant();
var output = new byte[cleanInput.Length * 5 / 8];
int buffer = 0;
int bitsLeft = 0;
int outputIndex = 0;
foreach (var c in cleanInput)
{
var val = Array.IndexOf(Base32Chars, c);
if (val < 0) continue;
buffer <<= 5;
buffer |= val & 0x1F;
bitsLeft += 5;
if (bitsLeft >= 8)
{
output[outputIndex++] = (byte)(buffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return output;
}
#endregion
}
+113
View File
@@ -0,0 +1,113 @@
using Lagerbestand.Models;
namespace Lagerbestand.Services;
/// <summary>
/// Simple session service to hold current user state.
/// Registered as Scoped - one instance per circuit/connection.
/// </summary>
public class UserSessionService
{
public AppUser? CurrentUser { get; private set; }
public bool IsAuthenticated => CurrentUser != null && CurrentUser.IsActive;
public bool IsAdmin => CurrentUser?.Role == UserRole.Admin;
/// <summary>
/// Worker role - has access to tire operations (checkin/checkout) and inventory list only
/// </summary>
public bool IsWorker => CurrentUser?.Role == UserRole.Worker;
/// <summary>
/// Check if user is Worker or Admin (for tire operations access)
/// </summary>
public bool IsWorkerOrAdmin => CurrentUser?.Role == UserRole.Admin || CurrentUser?.Role == UserRole.Worker;
public string DisplayName => CurrentUser?.DisplayName ?? CurrentUser?.Username ?? string.Empty;
public string Username => CurrentUser?.Username ?? string.Empty;
public Guid? UserId => CurrentUser?.Id;
public UserRole? Role => CurrentUser?.Role;
public string RoleName => CurrentUser?.Role switch
{
UserRole.Admin => "Administrator",
UserRole.Worker => "Mitarbeiter",
UserRole.User => "Benutzer",
_ => string.Empty
};
public event Action? OnChange;
public void SetUser(AppUser user)
{
CurrentUser = user;
NotifyStateChanged();
}
public void ClearUser()
{
CurrentUser = null;
NotifyStateChanged();
}
/// <summary>
/// Updates the current user data without triggering OnChange.
/// Useful for refreshing user data after profile updates.
/// </summary>
public void UpdateUser(AppUser user)
{
if (CurrentUser?.Id == user.Id)
{
CurrentUser = user;
NotifyStateChanged();
}
}
/// <summary>
/// Checks if the current user has at least the specified role level.
/// Admin > Worker > User
/// </summary>
public bool HasMinimumRole(UserRole minimumRole)
{
if (CurrentUser == null || !CurrentUser.IsActive)
return false;
return (int)CurrentUser.Role >= (int)minimumRole;
}
/// <summary>
/// Checks if the current user can manage the specified user.
/// Users can only be managed by admins or by themselves for limited operations.
/// </summary>
public bool CanManageUser(Guid targetUserId)
{
if (!IsAuthenticated)
return false;
// Admins can manage everyone
if (IsAdmin)
return true;
// Users can only manage themselves (limited)
return CurrentUser?.Id == targetUserId;
}
/// <summary>
/// Checks if the current user can delete the specified user.
/// Only admins can delete users, and they cannot delete themselves.
/// </summary>
public bool CanDeleteUser(Guid targetUserId)
{
if (!IsAdmin)
return false;
// Admins cannot delete themselves
return CurrentUser?.Id != targetUserId;
}
private void NotifyStateChanged() => OnChange?.Invoke();
}