Files
ReifenSpaeth/Lagerbestand/Services/DatabaseInitializer.cs
T

1384 lines
67 KiB
C#

using Lagerbestand.Data;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
namespace Lagerbestand.Services;
public interface IDatabaseInitializer
{
Task InitializeAsync(CancellationToken cancellationToken = default);
}
public class DatabaseInitializer : IDatabaseInitializer
{
private const int MaxRetries = 15;
private const int InitialRetryDelaySeconds = 2;
private const int MaxRetryDelaySeconds = 30;
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
private readonly ILogger<DatabaseInitializer> _logger;
public DatabaseInitializer(
IServiceProvider serviceProvider,
IConfiguration configuration,
ILogger<DatabaseInitializer> logger)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_logger = logger;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
var connectionString = _configuration.GetConnectionString("DefaultConnection");
_logger.LogInformation("Starte Datenbankinitialisierung für Server: {Server}",
GetServerFromConnectionString(connectionString));
var retryDelay = InitialRetryDelaySeconds;
for (var attempt = 1; attempt <= MaxRetries; attempt++)
{
try
{
_logger.LogInformation("Datenbankverbindung wird hergestellt (Versuch {Attempt}/{MaxRetries})...",
attempt, MaxRetries);
// First ensure the database exists
await EnsureDatabaseExistsAsync(cancellationToken);
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Test connection with timeout
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(30));
if (await context.Database.CanConnectAsync(cts.Token))
{
_logger.LogInformation("Datenbankverbindung erfolgreich hergestellt.");
await CreateTablesAsync(cancellationToken);
var authService = scope.ServiceProvider.GetRequiredService<IAuthService>();
await authService.EnsureAdminExistsAsync();
var storageLocationService = scope.ServiceProvider.GetRequiredService<IStorageLocationService>();
var seeded = await storageLocationService.SeedDefaultLocationsAsync();
if (seeded > 0)
_logger.LogInformation("{Count} Standard-Lagerplätze angelegt.", seeded);
_logger.LogInformation("Datenbankinitialisierung abgeschlossen.");
return;
}
_logger.LogWarning("Datenbankverbindung fehlgeschlagen (CanConnect returned false).");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Datenbankinitialisierung wurde abgebrochen.");
throw;
}
catch (MySqlException ex)
{
_logger.LogWarning(ex, "MySQL Fehler bei Datenbankinitialisierung (Versuch {Attempt}/{MaxRetries}): {Message}",
attempt, MaxRetries, ex.Message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Datenbankinitialisierung fehlgeschlagen (Versuch {Attempt}/{MaxRetries}): {Message}",
attempt, MaxRetries, ex.Message);
}
if (attempt < MaxRetries)
{
_logger.LogInformation("Warte {Delay} Sekunden vor dem nächsten Versuch...", retryDelay);
await Task.Delay(TimeSpan.FromSeconds(retryDelay), cancellationToken);
// Exponential backoff with max limit
retryDelay = Math.Min(retryDelay * 2, MaxRetryDelaySeconds);
}
}
_logger.LogError("Konnte keine Datenbankverbindung herstellen nach {MaxRetries} Versuchen. " +
"Bitte überprüfen Sie die Verbindungseinstellungen und die Erreichbarkeit des Datenbankservers.", MaxRetries);
}
private static string? GetServerFromConnectionString(string? connectionString)
{
if (string.IsNullOrEmpty(connectionString)) return null;
foreach (var part in connectionString.Split(';'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0].Trim().Equals("Server", StringComparison.OrdinalIgnoreCase))
return kv[1].Trim();
}
return null;
}
private async Task CreateTablesAsync(CancellationToken cancellationToken)
{
var connectionString = _configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string nicht gefunden.");
await using var connection = new MySqlConnection(connectionString);
// Set connection timeout
connection.ConnectionTimeout.ToString();
await connection.OpenAsync(cancellationToken);
// Core tables
await ExecuteSqlSafeAsync(connection, CreateCatalogItemsTableSql, "CatalogItems", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateInventoryItemsTableSql, "InventoryItems", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateUsersTableSql, "Users", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateSiteSettingsTableSql, "SiteSettings", cancellationToken);
// Tire management tables
await ExecuteSqlSafeAsync(connection, CreateCustomersTableSql, "Customers", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateVehiclesTableSql, "Vehicles", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateStorageLocationsTableSql, "StorageLocations", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireStacksTableSql, "TireStacks", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateTireItemsTableSql, "TireItems", cancellationToken);
// Kanban tables
await ExecuteSqlSafeAsync(connection, CreateKanbanColumnsTableSql, "KanbanColumns", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanLabelsTableSql, "KanbanLabels", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTasksTableSql, "KanbanTasks", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTodosTableSql, "KanbanTaskTodos", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskCommentsTableSql, "KanbanTaskComments", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskNotesTableSql, "KanbanTaskNotes", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskAttachmentsTableSql, "KanbanTaskAttachments", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplatesTableSql, "KanbanTaskTemplates", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplateTodosTableSql, "KanbanTaskTemplateTodos", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskLabelsTableSql, "KanbanTaskLabels", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTemplateLabelsTableSql, "KanbanTaskTemplateLabels", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskAssigneesTableSql, "KanbanTaskAssignees", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskCustomersTableSql, "KanbanTaskCustomers", cancellationToken);
await ExecuteSqlSafeAsync(connection, CreateKanbanTaskTireStacksTableSql, "KanbanTaskTireStacks", cancellationToken);
// Initialize default Kanban columns
await InitializeDefaultKanbanColumnsAsync(connection, cancellationToken);
// Migration: Add VehicleId column to TireStacks if it doesn't exist
await MigrateAddVehicleIdToTireStacksAsync(connection, cancellationToken);
// Migration: Migrate customer vehicle data to Vehicles table
await MigrateCustomerVehiclesToVehiclesTableAsync(connection, cancellationToken);
// Migration: Migrate old TireSets table to TireStacks if it exists
await MigrateTireSetsToTireStacksAsync(connection, cancellationToken);
// Migration: Migrate old StorageLocations schema to new ABCD format
await MigrateStorageLocationsSchemaAsync(connection, cancellationToken);
// Migration: Add Position column (Gebäude/Reihe/Seite-Ebene/Platz format) and reset test data
await MigrateStorageLocationsPositionAsync(connection, cancellationToken);
// Migration: Add FilePath column to KanbanTaskAttachments for external file storage
await MigrateAddFilePathToKanbanTaskAttachmentsAsync(connection, cancellationToken);
// Migration: Add NotesText column to KanbanTasks for simple task notes
await MigrateAddNotesTextToKanbanTasksAsync(connection, cancellationToken);
// Migration: Add IsActive column to TireStacks for soft delete
await MigrateAddIsActiveToTireStacksAsync(connection, cancellationToken);
// Migration: Remove LicensePlate/Year from Vehicles, add TireSize, make Make NOT NULL
await MigrateVehiclesRemoveLicensePlateAsync(connection, cancellationToken);
// Migration: Rename Kanban column "Warte auf wen anders" to "Warte auf Kunden"
await MigrateRenameKanbanWaitColumnAsync(connection, cancellationToken);
// Migration: Add TireSizeNote column to KanbanTasks for quick tire size notes
await MigrateAddTireSizeNoteToKanbanTasksAsync(connection, cancellationToken);
// Migration: Fix broken Kanban column icons (corrupted emoji in seed data)
await MigrateFixKanbanColumnIconsAsync(connection, cancellationToken);
// Migration: Add VehicleId column to KanbanTaskCustomers for vehicle selection
await MigrateAddVehicleIdToKanbanTaskCustomersAsync(connection, cancellationToken);
// Migration: Make Customer FirstName nullable
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Customers` MODIFY COLUMN `FirstName` VARCHAR(100) NULL;",
"MakeCustomerFirstNameNullable", cancellationToken);
// Migration: Add 2FA columns to Users table
await MigrateAdd2FaColumnsToUsersAsync(connection, cancellationToken);
// Cleanup: Drop legacy Customer columns (LicensePlate, VehicleMake, VehicleModel)
await DropColumnIfExistsAsync(connection, "Customers", "LicensePlate", cancellationToken);
await DropColumnIfExistsAsync(connection, "Customers", "VehicleMake", cancellationToken);
await DropColumnIfExistsAsync(connection, "Customers", "VehicleModel", cancellationToken);
await ExecuteSqlSafeAsync(connection, "ALTER TABLE `Customers` DROP INDEX `IX_Customers_LicensePlate`;", "DropCustomerLicensePlateIndex", cancellationToken);
// Cleanup old schema elements
await DropColumnIfExistsAsync(connection, "InventoryItems", "Quantity", cancellationToken);
await DropColumnIfExistsAsync(connection, "InventoryItems", "LastScannedAt", cancellationToken);
await DropColumnIfExistsAsync(connection, "InventoryItems", "Status", cancellationToken);
await DropColumnIfExistsAsync(connection, "InventoryItems", "RemovedAt", cancellationToken);
await ExecuteSqlSafeAsync(connection, "ALTER TABLE `InventoryItems` DROP INDEX `IX_InventoryItems_Status`;", "DropOldIndex", cancellationToken);
// Migration: Add Type column to CatalogItems for Reifen/Felgen category distinction
await MigrateAddTypeToCatalogItemsAsync(connection, cancellationToken);
// Migration: Add ItemCount column to TireStacks for variable inventory counts
await MigrateAddItemCountToTireStacksAsync(connection, cancellationToken);
_logger.LogInformation("Datenbanktabellen wurden erstellt/überprüft.");
}
private async Task MigrateTireSetsToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'TireSets';",
connection);
checkCmd.CommandTimeout = 30;
var tireSetsExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (tireSetsExists)
{
await using var countCmd = new MySqlCommand("SELECT COUNT(*) FROM `TireStacks`;", connection);
countCmd.CommandTimeout = 30;
var tireStacksCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
if (tireStacksCount == 0)
{
_logger.LogInformation("Migriere Daten von TireSets zu TireStacks...");
await ExecuteSqlSafeAsync(connection, """
INSERT INTO `TireStacks`
(`Id`, `TicketNumber`, `CustomerId`, `StorageLocationId`, `CatalogItemId`,
`Status`, `TireSize`, `TireBrand`, `TireModel`, `Season`, `TreadDepth`,
`DotCode`, `WithRims`, `RimSize`, `HasTpms`, `Condition`, `Notes`,
`StoredAt`, `PickedUpAt`, `ExpectedPickupDate`, `StoredByUserId`,
`PickedUpByUserId`, `CreatedAt`, `UpdatedAt`)
SELECT
`Id`, `TicketNumber`, `CustomerId`, `StorageLocationId`, `CatalogItemId`,
`Status`, `TireSize`, `TireBrand`, `TireModel`, `Season`, `TreadDepth`,
`DotCode`, `WithRims`, `RimSize`, `HasTpms`, 1, `Notes`,
`StoredAt`, `PickedUpAt`, `ExpectedPickupDate`, `StoredByUserId`,
`PickedUpByUserId`, `CreatedAt`, `UpdatedAt`
FROM `TireSets`;
""", "MigrateTireSetsData", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireItems` DROP FOREIGN KEY `FK_TireItems_TireSets`;",
"DropTireItemsFK", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireItems` CHANGE COLUMN `TireSetId` `TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL;",
"RenameTireSetIdColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireItems` ADD CONSTRAINT `FK_TireItems_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE;",
"AddTireItemsFK", cancellationToken);
_logger.LogInformation("Migration von TireSets zu TireStacks abgeschlossen.");
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration von TireSets zu TireStacks übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddVehicleIdToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Check if VehicleId column exists
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'VehicleId';",
connection);
checkCmd.CommandTimeout = 30;
var vehicleIdExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!vehicleIdExists)
{
_logger.LogInformation("Füge VehicleId Spalte zu TireStacks hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD COLUMN `VehicleId` CHAR(36) CHARACTER SET ascii NULL AFTER `CustomerId`;",
"AddVehicleIdColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD INDEX `IX_TireStacks_VehicleId` (`VehicleId`);",
"AddVehicleIdIndex", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD CONSTRAINT `FK_TireStacks_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL;",
"AddVehicleIdFK", cancellationToken);
_logger.LogInformation("VehicleId Spalte zu TireStacks hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für VehicleId übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateCustomerVehiclesToVehiclesTableAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Skip migration entirely if the legacy LicensePlate column no longer exists
await using var columnCheckCmd = new MySqlCommand("""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'Customers'
AND COLUMN_NAME = 'LicensePlate';
""", connection);
columnCheckCmd.CommandTimeout = 30;
var hasLegacyColumn = Convert.ToInt32(await columnCheckCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!hasLegacyColumn)
return;
// Check if there are customers with LicensePlate but no vehicles
await using var checkCmd = new MySqlCommand("""
SELECT COUNT(*) FROM `Customers` c
WHERE c.LicensePlate IS NOT NULL
AND c.LicensePlate != ''
AND NOT EXISTS (
SELECT 1 FROM `Vehicles` v
WHERE v.CustomerId = c.Id AND v.LicensePlate = c.LicensePlate
);
""", connection);
checkCmd.CommandTimeout = 30;
var customersToMigrate = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken));
if (customersToMigrate > 0)
{
_logger.LogInformation("Migriere {Count} Kundenfahrzeuge in Vehicles-Tabelle...", customersToMigrate);
// Insert vehicles from customer data
await ExecuteSqlSafeAsync(connection, """
INSERT INTO `Vehicles` (`Id`, `CustomerId`, `LicensePlate`, `Make`, `Model`, `IsPrimary`, `IsActive`, `CreatedAt`)
SELECT
UUID(),
c.Id,
c.LicensePlate,
c.VehicleMake,
c.VehicleModel,
1,
1,
COALESCE(c.CreatedAt, UTC_TIMESTAMP())
FROM `Customers` c
WHERE c.LicensePlate IS NOT NULL
AND c.LicensePlate != ''
AND NOT EXISTS (
SELECT 1 FROM `Vehicles` v
WHERE v.CustomerId = c.Id AND v.LicensePlate = c.LicensePlate
);
""", "MigrateCustomerVehicles", cancellationToken);
// Update TireStacks to reference the new vehicles
await ExecuteSqlSafeAsync(connection, """
UPDATE `TireStacks` ts
INNER JOIN `Vehicles` v ON v.CustomerId = ts.CustomerId AND v.IsPrimary = 1
SET ts.VehicleId = v.Id
WHERE ts.CustomerId IS NOT NULL AND ts.VehicleId IS NULL;
""", "UpdateTireStackVehicleIds", cancellationToken);
_logger.LogInformation("Kundenfahrzeuge erfolgreich migriert.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration der Kundenfahrzeuge übersprungen oder fehlgeschlagen.");
}
}
/// <summary>
/// Adds the Position column for the Gebäude/Reihe/Seite-Ebene/Platz code format.
/// Existing locations use the old code semantics and are only test data,
/// so they are removed; the default locations are re-seeded afterwards in InitializeAsync.
/// </summary>
private async Task MigrateStorageLocationsPositionAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'StorageLocations' AND column_name = 'Position';",
connection);
checkCmd.CommandTimeout = 30;
var positionColumnExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (positionColumnExists)
return;
_logger.LogInformation("Migriere StorageLocations auf Gebäude/Reihe/Seite-Ebene/Platz-Format...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `Position` INT NOT NULL DEFAULT 1 AFTER `Row`;",
"AddPositionColumn", cancellationToken);
// Old codes/rows are incompatible with the new format (test data only) - remove them
await ExecuteSqlSafeAsync(connection,
"UPDATE `TireStacks` SET `StorageLocationId` = NULL WHERE `StorageLocationId` IS NOT NULL;",
"DetachTireStacksFromLocations", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"DELETE FROM `StorageLocations`;",
"ClearOldStorageLocations", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD UNIQUE INDEX `IX_StorageLocations_Area_Row_Position_Slot` (`Area`, `Row`, `Position`, `SlotNumber`);",
"AddPositionIndex", cancellationToken);
_logger.LogInformation("StorageLocations Position-Migration abgeschlossen.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "StorageLocations Position-Migration übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateStorageLocationsSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Check if new columns exist
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'StorageLocations' AND column_name = 'Area';",
connection);
checkCmd.CommandTimeout = 30;
var areaColumnExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!areaColumnExists)
{
_logger.LogInformation("Migriere StorageLocations Schema zu ABCD-Format...");
// Add new columns
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `Area` INT NOT NULL DEFAULT 1;",
"AddAreaColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `SlotNumber` INT NOT NULL DEFAULT 1;",
"AddSlotNumberColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `IsOccupied` TINYINT(1) NOT NULL DEFAULT 0;",
"AddIsOccupiedColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `TireStackId` CHAR(36) CHARACTER SET ascii NULL;",
"AddTireStackIdColumn", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD COLUMN `OccupiedSince` DATETIME(6) NULL;",
"AddOccupiedSinceColumn", cancellationToken);
// Add index and foreign key for TireStackId
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_TireStackId` (`TireStackId`);",
"AddTireStackIdIndex", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_Area_Row` (`Area`, `Row`);",
"AddAreaRowIndex", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` ADD INDEX `IX_StorageLocations_IsOccupied` (`IsOccupied`);",
"AddIsOccupiedIndex", cancellationToken);
// Modify Row column to INT
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `StorageLocations` MODIFY COLUMN `Row` INT NOT NULL DEFAULT 1;",
"ModifyRowColumn", cancellationToken);
// Drop old columns that are no longer needed
await DropColumnIfExistsAsync(connection, "StorageLocations", "Section", cancellationToken);
await DropColumnIfExistsAsync(connection, "StorageLocations", "Capacity", cancellationToken);
await DropColumnIfExistsAsync(connection, "StorageLocations", "Name", cancellationToken);
await DropColumnIfExistsAsync(connection, "StorageLocations", "QrCode", cancellationToken);
_logger.LogInformation("StorageLocations Schema-Migration abgeschlossen.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "StorageLocations Schema-Migration übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddFilePathToKanbanTaskAttachmentsAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Check if FilePath column exists
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTaskAttachments' AND column_name = 'FilePath';",
connection);
checkCmd.CommandTimeout = 30;
var filePathExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!filePathExists)
{
_logger.LogInformation("Füge FilePath Spalte zu KanbanTaskAttachments hinzu...");
// Add FilePath column
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTaskAttachments` ADD COLUMN `FilePath` VARCHAR(500) NULL AFTER `FileSize`;",
"AddFilePathColumn", cancellationToken);
// Make FileContent nullable for external storage support
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTaskAttachments` MODIFY COLUMN `FileContent` LONGBLOB NULL;",
"MakeFileContentNullable", cancellationToken);
_logger.LogInformation("FilePath Spalte zu KanbanTaskAttachments hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für FilePath übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddNotesTextToKanbanTasksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Check if NotesText column exists
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTasks' AND column_name = 'NotesText';",
connection);
checkCmd.CommandTimeout = 30;
var notesTextExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!notesTextExists)
{
_logger.LogInformation("Füge NotesText Spalte zu KanbanTasks hinzu...");
// Add NotesText column
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTasks` ADD COLUMN `NotesText` LONGTEXT NULL AFTER `ArchivedAt`;",
"AddNotesTextColumn", cancellationToken);
_logger.LogInformation("NotesText Spalte zu KanbanTasks hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für NotesText übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddIsActiveToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'IsActive';",
connection);
checkCmd.CommandTimeout = 30;
var isActiveExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!isActiveExists)
{
_logger.LogInformation("Füge IsActive Spalte zu TireStacks hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD COLUMN `IsActive` TINYINT(1) NOT NULL DEFAULT 1;",
"AddIsActiveToTireStacks", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD INDEX `IX_TireStacks_IsActive` (`IsActive`);",
"AddIsActiveIndex", cancellationToken);
_logger.LogInformation("IsActive Spalte zu TireStacks hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für TireStacks.IsActive übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateVehiclesRemoveLicensePlateAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Check if TireSize column already exists (migration already done)
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Vehicles' AND column_name = 'TireSize';",
connection);
checkCmd.CommandTimeout = 30;
var tireSizeExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!tireSizeExists)
{
_logger.LogInformation("Migriere Vehicles-Tabelle: Entferne LicensePlate/Year, füge TireSize hinzu...");
// Add TireSize column
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` ADD COLUMN `TireSize` VARCHAR(50) NOT NULL DEFAULT '';",
"AddTireSizeToVehicles", cancellationToken);
// Make Make NOT NULL with default
await ExecuteSqlSafeAsync(connection,
"UPDATE `Vehicles` SET `Make` = COALESCE(`Make`, 'Unbekannt') WHERE `Make` IS NULL;",
"SetDefaultMake", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` MODIFY COLUMN `Make` VARCHAR(100) NOT NULL;",
"MakeMakeNotNull", cancellationToken);
// Drop LicensePlate index, then column
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` DROP INDEX `IX_Vehicles_LicensePlate`;",
"DropVehicleLicensePlateIndex", cancellationToken);
await DropColumnIfExistsAsync(connection, "Vehicles", "LicensePlate", cancellationToken);
await DropColumnIfExistsAsync(connection, "Vehicles", "Year", cancellationToken);
// Add new index on Make/Model
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Vehicles` ADD INDEX `IX_Vehicles_Make_Model` (`Make`, `Model`);",
"AddVehicleMakeModelIndex", cancellationToken);
_logger.LogInformation("Vehicles-Tabelle erfolgreich migriert.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für Vehicles (LicensePlate-Entfernung) übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAdd2FaColumnsToUsersAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Users` ADD COLUMN `TwoFactorEnabled` TINYINT(1) NOT NULL DEFAULT 0;",
"AddTwoFactorEnabled", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Users` ADD COLUMN `TwoFactorSecret` VARCHAR(256) NULL;",
"AddTwoFactorSecret", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `Users` ADD COLUMN `TwoFactorEnforced` TINYINT(1) NOT NULL DEFAULT 0;",
"AddTwoFactorEnforced", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für 2FA-Spalten übersprungen oder fehlgeschlagen.");
}
}
private async Task ExecuteSqlSafeAsync(MySqlConnection connection, string sql, string name, CancellationToken cancellationToken)
{
try
{
await using var cmd = new MySqlCommand(sql, connection);
cmd.CommandTimeout = 60; // 60 second timeout for DDL operations
await cmd.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation("SQL ausgeführt: {Name}", name);
}
catch (MySqlException ex) when (ex.Number is 1091 or 1061 or 1826 or 121 or 1050 or 1025 or 1553 or 1146 or 1060 or 1068)
{
// 1091: Can't DROP - doesn't exist
// 1061: Duplicate key name
// 1826: Duplicate foreign key constraint name
// 121: Duplicate key on write or update
// 1050: Table already exists
// 1025: Error on rename (constraint doesn't exist)
// 1553: Cannot drop check constraint (doesn't exist)
// 1146: Table doesn't exist
// 1060: Duplicate column name
// 1068: Multiple primary key defined
_logger.LogDebug("SQL übersprungen (bereits vorhanden oder nicht nötig): {Name}", name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SQL Fehler (ignoriert): {Name}", name);
}
}
private async Task DropColumnIfExistsAsync(MySqlConnection connection, string table, string column, CancellationToken cancellationToken)
{
try
{
await using var cmd = new MySqlCommand($"ALTER TABLE `{table}` DROP COLUMN `{column}`;", connection);
cmd.CommandTimeout = 60;
await cmd.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation("Spalte '{Column}' aus '{Table}' entfernt.", column, table);
}
catch
{
// Column doesn't exist - ignore
}
}
private async Task EnsureDatabaseExistsAsync(CancellationToken cancellationToken)
{
var connectionString = _configuration.GetConnectionString("DefaultConnection");
if (string.IsNullOrWhiteSpace(connectionString)) return;
var dbName = GetDatabaseName(connectionString);
if (string.IsNullOrWhiteSpace(dbName)) return;
var serverConnStr = GetServerConnectionString(connectionString);
var server = GetServerFromConnectionString(connectionString);
var port = GetPortFromConnectionString(connectionString) ?? "3306";
_logger.LogInformation("Prüfe ob Datenbank '{Database}' auf Server '{Server}:{Port}' existiert...",
dbName, server, port);
await using var connection = new MySqlConnection(serverConnStr);
await connection.OpenAsync(cancellationToken);
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"CREATE DATABASE IF NOT EXISTS `{dbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
cmd.CommandTimeout = 30;
await cmd.ExecuteNonQueryAsync(cancellationToken);
_logger.LogInformation("Datenbank '{Database}' ist verfügbar.", dbName);
}
private static string? GetPortFromConnectionString(string? connectionString)
{
if (string.IsNullOrEmpty(connectionString)) return null;
foreach (var part in connectionString.Split(';'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0].Trim().Equals("Port", StringComparison.OrdinalIgnoreCase))
return kv[1].Trim();
}
return null;
}
private static string? GetDatabaseName(string connectionString)
{
foreach (var part in connectionString.Split(';'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0].Trim().Equals("Database", StringComparison.OrdinalIgnoreCase))
return kv[1].Trim();
}
return null;
}
private static string GetServerConnectionString(string connectionString)
{
var parts = connectionString.Split(';')
.Where(p => !p.Trim().StartsWith("Database=", StringComparison.OrdinalIgnoreCase));
return string.Join(";", parts);
}
private async Task MigrateAddTypeToCatalogItemsAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'CatalogItems' AND column_name = 'Type';",
connection);
checkCmd.CommandTimeout = 30;
var typeExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!typeExists)
{
_logger.LogInformation("Füge Type Spalte zu CatalogItems hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `CatalogItems` ADD COLUMN `Type` INT NOT NULL DEFAULT 0;",
"AddTypeToCatalogItems", cancellationToken);
_logger.LogInformation("Type Spalte zu CatalogItems hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für CatalogItems.Type übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddItemCountToTireStacksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'TireStacks' AND column_name = 'ItemCount';",
connection);
checkCmd.CommandTimeout = 30;
var itemCountExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!itemCountExists)
{
_logger.LogInformation("Füge ItemCount Spalte zu TireStacks hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `TireStacks` ADD COLUMN `ItemCount` INT NOT NULL DEFAULT 4;",
"AddItemCountToTireStacks", cancellationToken);
_logger.LogInformation("ItemCount Spalte zu TireStacks hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für TireStacks.ItemCount übersprungen oder fehlgeschlagen.");
}
}
#region SQL Statements
private const string CreateCatalogItemsTableSql = """
CREATE TABLE IF NOT EXISTS `CatalogItems` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Name` VARCHAR(200) NOT NULL,
`PictureFileName` VARCHAR(500) NULL,
`TireSize` VARCHAR(50) NOT NULL DEFAULT '',
`Season` INT NOT NULL DEFAULT 0,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateInventoryItemsTableSql = """
CREATE TABLE IF NOT EXISTS `InventoryItems` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`QrCode` VARCHAR(500) NOT NULL,
`Name` VARCHAR(200) NULL,
`Description` VARCHAR(500) NULL,
`CatalogItemId` CHAR(36) CHARACTER SET ascii NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
INDEX `IX_InventoryItems_QrCode` (`QrCode`),
INDEX `IX_InventoryItems_CatalogItemId` (`CatalogItemId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateUsersTableSql = """
CREATE TABLE IF NOT EXISTS `Users` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Username` VARCHAR(100) NOT NULL,
`PasswordHash` VARCHAR(256) NOT NULL,
`DisplayName` VARCHAR(200) NULL,
`Role` INT NOT NULL DEFAULT 0,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`LastLoginAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
UNIQUE INDEX `IX_Users_Username` (`Username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateSiteSettingsTableSql = """
CREATE TABLE IF NOT EXISTS `SiteSettings` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Key` VARCHAR(100) NOT NULL,
`Value` TEXT NOT NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
UNIQUE INDEX `IX_SiteSettings_Key` (`Key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateCustomersTableSql = """
CREATE TABLE IF NOT EXISTS `Customers` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`CustomerNumber` VARCHAR(20) NOT NULL,
`FirstName` VARCHAR(100) NOT NULL,
`LastName` VARCHAR(100) NOT NULL,
`Company` VARCHAR(200) NULL,
`Phone` VARCHAR(20) NULL,
`Email` VARCHAR(100) NULL,
`Notes` VARCHAR(500) NULL,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
UNIQUE INDEX `IX_Customers_CustomerNumber` (`CustomerNumber`),
INDEX `IX_Customers_Name` (`LastName`, `FirstName`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateVehiclesTableSql = """
CREATE TABLE IF NOT EXISTS `Vehicles` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`CustomerId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Make` VARCHAR(100) NOT NULL,
`Model` VARCHAR(100) NULL,
`TireSize` VARCHAR(50) NOT NULL DEFAULT '',
`Notes` VARCHAR(500) NULL,
`IsPrimary` TINYINT(1) NOT NULL DEFAULT 0,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
INDEX `IX_Vehicles_CustomerId` (`CustomerId`),
INDEX `IX_Vehicles_Make_Model` (`Make`, `Model`),
INDEX `IX_Vehicles_CustomerId_IsPrimary` (`CustomerId`, `IsPrimary`),
CONSTRAINT `FK_Vehicles_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
/// <summary>
/// StorageLocations table with ABCD code format:
/// - Code: 4-digit code (e.g., "2235")
/// - Area: 1=Werkstatt (3 Reihen), 2=Lager (5 Reihen)
/// - Row: Werkstatt 1-3, Lager 1-5
/// - Position: 1=links oben, 2=links unten, 3=rechts oben, 4=rechts unten
/// - SlotNumber: 1-9 (längs)
/// - Each location holds exactly one tire stack
/// </summary>
private const string CreateStorageLocationsTableSql = """
CREATE TABLE IF NOT EXISTS `StorageLocations` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Code` VARCHAR(4) NOT NULL,
`Area` INT NOT NULL DEFAULT 1,
`Row` INT NOT NULL DEFAULT 1,
`Position` INT NOT NULL DEFAULT 1,
`SlotNumber` INT NOT NULL DEFAULT 1,
`IsOccupied` TINYINT(1) NOT NULL DEFAULT 0,
`TireStackId` CHAR(36) CHARACTER SET ascii NULL,
`OccupiedSince` DATETIME(6) NULL,
`Notes` VARCHAR(500) NULL,
`IsAvailable` TINYINT(1) NOT NULL DEFAULT 1,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
UNIQUE INDEX `IX_StorageLocations_Code` (`Code`),
INDEX `IX_StorageLocations_Area_Row` (`Area`, `Row`),
UNIQUE INDEX `IX_StorageLocations_Area_Row_Position_Slot` (`Area`, `Row`, `Position`, `SlotNumber`),
INDEX `IX_StorageLocations_IsOccupied` (`IsOccupied`),
INDEX `IX_StorageLocations_TireStackId` (`TireStackId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateTireStacksTableSql = """
CREATE TABLE IF NOT EXISTS `TireStacks` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TicketNumber` VARCHAR(50) NOT NULL,
`CustomerId` CHAR(36) CHARACTER SET ascii NULL,
`VehicleId` CHAR(36) CHARACTER SET ascii NULL,
`StorageLocationId` CHAR(36) CHARACTER SET ascii NULL,
`CatalogItemId` CHAR(36) CHARACTER SET ascii NULL,
`Status` INT NOT NULL DEFAULT 0,
`TireSize` VARCHAR(50) NULL,
`TireBrand` VARCHAR(100) NULL,
`TireModel` VARCHAR(100) NULL,
`Season` INT NOT NULL DEFAULT 0,
`TreadDepth` DECIMAL(4,1) NULL,
`DotCode` VARCHAR(20) NULL,
`WithRims` TINYINT(1) NOT NULL DEFAULT 1,
`RimSize` VARCHAR(20) NULL,
`HasTpms` TINYINT(1) NOT NULL DEFAULT 0,
`Condition` INT NOT NULL DEFAULT 1,
`Notes` VARCHAR(1000) NULL,
`StoredAt` DATETIME(6) NOT NULL,
`PickedUpAt` DATETIME(6) NULL,
`ExpectedPickupDate` DATETIME(6) NULL,
`StoredByUserId` CHAR(36) CHARACTER SET ascii NULL,
`PickedUpByUserId` CHAR(36) CHARACTER SET ascii NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
UNIQUE INDEX `IX_TireStacks_TicketNumber` (`TicketNumber`),
INDEX `IX_TireStacks_Status` (`Status`),
INDEX `IX_TireStacks_CustomerId` (`CustomerId`),
INDEX `IX_TireStacks_VehicleId` (`VehicleId`),
INDEX `IX_TireStacks_StorageLocationId` (`StorageLocationId`),
CONSTRAINT `FK_TireStacks_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE RESTRICT,
CONSTRAINT `FK_TireStacks_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL,
CONSTRAINT `FK_TireStacks_StorageLocations` FOREIGN KEY (`StorageLocationId`) REFERENCES `StorageLocations` (`Id`) ON DELETE SET NULL,
CONSTRAINT `FK_TireStacks_CatalogItems` FOREIGN KEY (`CatalogItemId`) REFERENCES `CatalogItems` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateTireItemsTableSql = """
CREATE TABLE IF NOT EXISTS `TireItems` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Position` INT NOT NULL DEFAULT 0,
`TreadDepth` DECIMAL(4,1) NULL,
`Condition` INT NOT NULL DEFAULT 1,
`DamageNotes` VARCHAR(500) NULL,
`DotCode` VARCHAR(20) NULL,
`PhotoFileName` VARCHAR(500) NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
INDEX `IX_TireItems_TireStackId` (`TireStackId`),
CONSTRAINT `FK_TireItems_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
#region Kanban SQL Statements
private const string CreateKanbanColumnsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanColumns` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Name` VARCHAR(100) NOT NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
`Color` VARCHAR(7) NULL,
`Icon` VARCHAR(10) NULL,
`IsDefault` TINYINT(1) NOT NULL DEFAULT 0,
`IsCompletedColumn` TINYINT(1) NOT NULL DEFAULT 0,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanColumns_SortOrder` (`SortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanLabelsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanLabels` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Name` VARCHAR(50) NOT NULL,
`Color` VARCHAR(7) NOT NULL DEFAULT '#6B7280',
`Description` VARCHAR(200) NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanLabels_SortOrder` (`SortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTasksTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTasks` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Title` VARCHAR(200) NOT NULL,
`Description` LONGTEXT NULL,
`ColumnId` CHAR(36) CHARACTER SET ascii NOT NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
`DueDate` DATETIME(6) NULL,
`Priority` INT NOT NULL DEFAULT 1,
`EstimatedMinutes` INT NULL,
`CreatedByUserId` CHAR(36) CHARACTER SET ascii NULL,
`IsArchived` TINYINT(1) NOT NULL DEFAULT 0,
`CompletedAt` DATETIME(6) NULL,
`ArchivedAt` DATETIME(6) NULL,
`NotesText` LONGTEXT NULL,
`TireSizeNote` VARCHAR(100) NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTasks_ColumnId` (`ColumnId`),
INDEX `IX_KanbanTasks_DueDate` (`DueDate`),
INDEX `IX_KanbanTasks_IsArchived` (`IsArchived`),
INDEX `IX_KanbanTasks_ColumnId_SortOrder` (`ColumnId`, `SortOrder`),
CONSTRAINT `FK_KanbanTasks_KanbanColumns` FOREIGN KEY (`ColumnId`) REFERENCES `KanbanColumns` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTasks_Users` FOREIGN KEY (`CreatedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskTodosTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskTodos` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Text` VARCHAR(500) NOT NULL,
`IsCompleted` TINYINT(1) NOT NULL DEFAULT 0,
`SortOrder` INT NOT NULL DEFAULT 0,
`CreatedAt` DATETIME(6) NOT NULL,
`CompletedAt` DATETIME(6) NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTaskTodos_TaskId` (`TaskId`),
CONSTRAINT `FK_KanbanTaskTodos_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskCommentsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskComments` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`UserId` CHAR(36) CHARACTER SET ascii NULL,
`Content` LONGTEXT NOT NULL,
`CommentType` INT NOT NULL DEFAULT 0,
`CreatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTaskComments_TaskId` (`TaskId`),
INDEX `IX_KanbanTaskComments_CreatedAt` (`CreatedAt`),
CONSTRAINT `FK_KanbanTaskComments_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskComments_Users` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskNotesTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskNotes` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Title` VARCHAR(100) NOT NULL DEFAULT 'Notizen',
`Content` LONGTEXT NOT NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
`LastEditedByUserId` CHAR(36) CHARACTER SET ascii NULL,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTaskNotes_TaskId` (`TaskId`),
CONSTRAINT `FK_KanbanTaskNotes_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskNotes_Users` FOREIGN KEY (`LastEditedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskAttachmentsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskAttachments` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`FileName` VARCHAR(255) NOT NULL,
`ContentType` VARCHAR(100) NOT NULL DEFAULT 'application/octet-stream',
`FileSize` BIGINT NOT NULL DEFAULT 0,
`FilePath` VARCHAR(500) NULL,
`FileContent` LONGBLOB NULL,
`UploadedByUserId` CHAR(36) CHARACTER SET ascii NULL,
`CreatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTaskAttachments_TaskId` (`TaskId`),
CONSTRAINT `FK_KanbanTaskAttachments_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskAttachments_Users` FOREIGN KEY (`UploadedByUserId`) REFERENCES `Users` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskTemplatesTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskTemplates` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`Name` VARCHAR(100) NOT NULL,
`Description` VARCHAR(500) NULL,
`TitlePattern` VARCHAR(200) NOT NULL DEFAULT '',
`DefaultDescription` LONGTEXT NULL,
`DefaultColumnId` CHAR(36) CHARACTER SET ascii NULL,
`DefaultPriority` INT NOT NULL DEFAULT 1,
`DefaultEstimatedMinutes` INT NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
`IsActive` TINYINT(1) NOT NULL DEFAULT 1,
`CreatedAt` DATETIME(6) NOT NULL,
`UpdatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`Id`),
INDEX `IX_KanbanTaskTemplates_SortOrder` (`SortOrder`),
CONSTRAINT `FK_KanbanTaskTemplates_KanbanColumns` FOREIGN KEY (`DefaultColumnId`) REFERENCES `KanbanColumns` (`Id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskTemplateTodosTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskTemplateTodos` (
`Id` CHAR(36) CHARACTER SET ascii NOT NULL,
`TemplateId` CHAR(36) CHARACTER SET ascii NOT NULL,
`Text` VARCHAR(500) NOT NULL,
`SortOrder` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_KanbanTaskTemplateTodos_KanbanTaskTemplates` FOREIGN KEY (`TemplateId`) REFERENCES `KanbanTaskTemplates` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskLabelsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskLabels` (
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`LabelId` CHAR(36) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`TaskId`, `LabelId`),
CONSTRAINT `FK_KanbanTaskLabels_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskLabels_KanbanLabels` FOREIGN KEY (`LabelId`) REFERENCES `KanbanLabels` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskTemplateLabelsTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskTemplateLabels` (
`TemplateId` CHAR(36) CHARACTER SET ascii NOT NULL,
`LabelId` CHAR(36) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`TemplateId`, `LabelId`),
CONSTRAINT `FK_KanbanTaskTemplateLabels_KanbanTaskTemplates` FOREIGN KEY (`TemplateId`) REFERENCES `KanbanTaskTemplates` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskTemplateLabels_KanbanLabels` FOREIGN KEY (`LabelId`) REFERENCES `KanbanLabels` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskAssigneesTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskAssignees` (
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`UserId` CHAR(36) CHARACTER SET ascii NOT NULL,
`AssignedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`TaskId`, `UserId`),
CONSTRAINT `FK_KanbanTaskAssignees_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskAssignees_Users` FOREIGN KEY (`UserId`) REFERENCES `Users` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskCustomersTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskCustomers` (
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`CustomerId` CHAR(36) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`TaskId`, `CustomerId`),
CONSTRAINT `FK_KanbanTaskCustomers_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskCustomers_Customers` FOREIGN KEY (`CustomerId`) REFERENCES `Customers` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private const string CreateKanbanTaskTireStacksTableSql = """
CREATE TABLE IF NOT EXISTS `KanbanTaskTireStacks` (
`TaskId` CHAR(36) CHARACTER SET ascii NOT NULL,
`TireStackId` CHAR(36) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`TaskId`, `TireStackId`),
CONSTRAINT `FK_KanbanTaskTireStacks_KanbanTasks` FOREIGN KEY (`TaskId`) REFERENCES `KanbanTasks` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_KanbanTaskTireStacks_TireStacks` FOREIGN KEY (`TireStackId`) REFERENCES `TireStacks` (`Id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
private async Task MigrateAddTireSizeNoteToKanbanTasksAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTasks' AND column_name = 'TireSizeNote';",
connection);
checkCmd.CommandTimeout = 30;
var tireSizeNoteExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!tireSizeNoteExists)
{
_logger.LogInformation("Füge TireSizeNote Spalte zu KanbanTasks hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTasks` ADD COLUMN `TireSizeNote` VARCHAR(100) NULL AFTER `NotesText`;",
"AddTireSizeNoteColumn", cancellationToken);
_logger.LogInformation("TireSizeNote Spalte zu KanbanTasks hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für TireSizeNote übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateRenameKanbanWaitColumnAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await ExecuteSqlSafeAsync(connection,
"UPDATE `KanbanColumns` SET `Name` = 'Warte auf Kunden', `Icon` = '\u23F3' WHERE `Name` = 'Warte auf wen anders';",
"RenameKanbanWaitColumn", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fehler beim Umbenennen der Kanban-Spalte.");
}
}
private async Task MigrateFixKanbanColumnIconsAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
// Fix icons that were corrupted due to encoding issues in original seed data
var iconFixes = new[]
{
(Name: "Backlog", Icon: "\U0001F4E5"),
(Name: "ToDo", Icon: "\U0001F4CB"),
(Name: "In Arbeit", Icon: "\U0001F504"),
(Name: "Warte auf Kunden", Icon: "\u23F3"),
(Name: "Erledigt", Icon: "\u2705")
};
foreach (var fix in iconFixes)
{
await using var cmd = new MySqlCommand(
"UPDATE `KanbanColumns` SET `Icon` = @icon WHERE `Name` = @name AND (`Icon` IS NULL OR `Icon` = '' OR `Icon` LIKE '%?%');",
connection);
cmd.CommandTimeout = 30;
cmd.Parameters.AddWithValue("@icon", fix.Icon);
cmd.Parameters.AddWithValue("@name", fix.Name);
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken);
if (affected > 0)
{
_logger.LogInformation("Kanban-Spalte '{Name}' Icon korrigiert.", fix.Name);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für Kanban-Spalten-Icons übersprungen oder fehlgeschlagen.");
}
}
private async Task MigrateAddVehicleIdToKanbanTaskCustomersAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'KanbanTaskCustomers' AND column_name = 'VehicleId';",
connection);
checkCmd.CommandTimeout = 30;
var vehicleIdExists = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken)) > 0;
if (!vehicleIdExists)
{
_logger.LogInformation("Füge VehicleId Spalte zu KanbanTaskCustomers hinzu...");
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTaskCustomers` ADD COLUMN `VehicleId` CHAR(36) CHARACTER SET ascii NULL;",
"AddVehicleIdToKanbanTaskCustomers", cancellationToken);
await ExecuteSqlSafeAsync(connection,
"ALTER TABLE `KanbanTaskCustomers` ADD CONSTRAINT `FK_KanbanTaskCustomers_Vehicles` FOREIGN KEY (`VehicleId`) REFERENCES `Vehicles` (`Id`) ON DELETE SET NULL;",
"AddVehicleIdFK", cancellationToken);
_logger.LogInformation("VehicleId Spalte zu KanbanTaskCustomers hinzugefügt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Migration für VehicleId in KanbanTaskCustomers übersprungen oder fehlgeschlagen.");
}
}
private async Task InitializeDefaultKanbanColumnsAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
try
{
await using var checkCmd = new MySqlCommand("SELECT COUNT(*) FROM `KanbanColumns`;", connection);
checkCmd.CommandTimeout = 30;
var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync(cancellationToken));
if (count == 0)
{
_logger.LogInformation("Erstelle Standard-Kanban-Spalten...");
var columns = new[]
{
(Name: "Backlog", Order: 0, Icon: "\U0001F4E5", IsDefault: true, IsCompleted: false),
(Name: "ToDo", Order: 1, Icon: "\U0001F4CB", IsDefault: false, IsCompleted: false),
(Name: "In Arbeit", Order: 2, Icon: "\U0001F504", IsDefault: false, IsCompleted: false),
(Name: "Warte auf Kunden", Order: 3, Icon: "\u23F3", IsDefault: false, IsCompleted: false),
(Name: "Erledigt", Order: 4, Icon: "\u2705", IsDefault: false, IsCompleted: true)
};
foreach (var col in columns)
{
var id = Guid.NewGuid().ToString();
var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.ffffff");
var sql = $"""
INSERT INTO `KanbanColumns` (`Id`, `Name`, `SortOrder`, `Icon`, `IsDefault`, `IsCompletedColumn`, `IsActive`, `CreatedAt`, `UpdatedAt`)
VALUES ('{id}', '{col.Name}', {col.Order}, '{col.Icon}', {(col.IsDefault ? 1 : 0)}, {(col.IsCompleted ? 1 : 0)}, 1, '{now}', '{now}');
""";
await ExecuteSqlSafeAsync(connection, sql, $"InsertColumn_{col.Name}", cancellationToken);
}
_logger.LogInformation("Standard-Kanban-Spalten erstellt.");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Fehler beim Erstellen der Standard-Kanban-Spalten.");
}
}
#endregion
#endregion
}