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 _logger; public DatabaseInitializer( IServiceProvider serviceProvider, IConfiguration configuration, ILogger 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(); // 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(); await authService.EnsureAdminExistsAsync(); var storageLocationService = scope.ServiceProvider.GetRequiredService(); 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."); } } /// /// 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. /// 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; """; /// /// 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 /// 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 }