diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go old mode 100755 new mode 100644 index f01c98f..c50b57d --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -5,6 +5,8 @@ import ( "fmt" "log" "os" + "path/filepath" + "sort" "strings" "time" @@ -49,48 +51,123 @@ func BuildConnectionString() (string, error) { } func RunMigrations() { - files, err := os.ReadDir("migrations") + migrationDir, err := resolveMigrationDir() if err != nil { - // Try fallback to relative path if running from cmd/api - files, err = os.ReadDir("../../migrations") - if err != nil { - log.Printf("⚠️ Warning: Could not list migrations directory: %v", err) - return - } + log.Printf("⚠️ Warning: Could not list migrations directory: %v", err) + return } - // Sort files by name to ensure order (001, 002, ...) - // ReadDir returns sorted by name automatically, but let's be safe if logic changes? - // Actually ReadDir result is sorted by filename. + if err := ensureMigrationsTable(); err != nil { + log.Fatalf("❌ Error ensuring migrations table: %v", err) + } + + entries, err := os.ReadDir(migrationDir) + if err != nil { + log.Fatalf("❌ Error reading migrations directory: %v", err) + } + + var files []os.DirEntry + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + files = append(files, entry) + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Name() < files[j].Name() + }) + + warnDuplicateMigrationPrefixes(files) for _, file := range files { - if file.IsDir() { + name := file.Name() + if isMigrationApplied(name) { + log.Printf("⏭️ Migration %s skipped (already tracked)", name) continue } - log.Printf("📦 Running migration: %s", file.Name()) - content, err := os.ReadFile("migrations/" + file.Name()) + path := filepath.Join(migrationDir, name) + content, err := os.ReadFile(path) if err != nil { - // Try fallback - content, err = os.ReadFile("../../migrations/" + file.Name()) - if err != nil { - log.Fatalf("❌ Error reading migration file %s: %v", file.Name(), err) - } + log.Fatalf("❌ Error reading migration file %s: %v", name, err) } - _, err = DB.Exec(string(content)) - if err != nil { - errStr := err.Error() - // Check if it's an "already exists" error - these are safe to skip - if strings.Contains(errStr, "already exists") { - log.Printf("⏭️ Migration %s skipped (already applied)", file.Name()) - } else { - // Real error - log it - log.Printf("❌ Error running migration %s: %v", file.Name(), err) - } - } else { - log.Printf("✅ Migration %s executed successfully", file.Name()) + log.Printf("📦 Running migration: %s", name) + if err := executeMigration(name, string(content)); err != nil { + log.Fatalf("❌ Error running migration %s: %v", name, err) } + log.Printf("✅ Migration %s executed successfully", name) } + log.Println("All migrations processed") } + +func resolveMigrationDir() (string, error) { + candidateDirs := []string{"migrations", "../../migrations"} + + for _, dir := range candidateDirs { + if _, err := os.ReadDir(dir); err == nil { + return dir, nil + } + } + + return "", fmt.Errorf("migrations directory not found in any known location") +} + +func ensureMigrationsTable() error { + _, err := DB.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `) + return err +} + +func isMigrationApplied(filename string) bool { + var exists bool + err := DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)", filename).Scan(&exists) + if err != nil { + log.Fatalf("❌ Error checking migration %s: %v", filename, err) + } + return exists +} + +func executeMigration(filename, sqlContent string) error { + tx, err := DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(sqlContent); err != nil { + errStr := strings.ToLower(err.Error()) + if !strings.Contains(errStr, "already exists") { + return err + } + log.Printf("⏭️ Migration %s skipped due to existing resources", filename) + } + + if _, err := tx.Exec("INSERT INTO schema_migrations (filename) VALUES ($1)", filename); err != nil { + return err + } + + return tx.Commit() +} + +func warnDuplicateMigrationPrefixes(files []os.DirEntry) { + prefixCount := make(map[string]int) + for _, file := range files { + parts := strings.SplitN(file.Name(), "_", 2) + if len(parts) == 2 { + prefixCount[parts[0]]++ + } + } + + for prefix, count := range prefixCount { + if count > 1 { + log.Printf("⚠️ Duplicate migration prefix detected (%s appears %d times)", prefix, count) + } + } +} diff --git a/backend/internal/database/database_test.go b/backend/internal/database/database_test.go index 032174b..a0983e6 100644 --- a/backend/internal/database/database_test.go +++ b/backend/internal/database/database_test.go @@ -60,8 +60,22 @@ func TestRunMigrations(t *testing.T) { t.Fatalf("Failed to write migration file: %v", err) } - // Mock Expectation + // Mock Expectations + mock.ExpectExec(regexp.QuoteMeta(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `)).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")). + WithArgs("001_test.sql"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + mock.ExpectBegin() mock.ExpectExec(regexp.QuoteMeta(content)).WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO schema_migrations (filename) VALUES ($1)")). + WithArgs("001_test.sql"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() // Run database.RunMigrations() diff --git a/backoffice/package.json b/backoffice/package.json index e188ff1..a2e4723 100644 --- a/backoffice/package.json +++ b/backoffice/package.json @@ -13,12 +13,13 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" }, "dependencies": { "@fastify/compress": "^8.0.1", @@ -98,4 +99,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8d0c21b..c5d2574 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,6 +64,7 @@ "zustand": "^4.5.7" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.1", "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4.1.9", "@testing-library/dom": "^10.4.1", @@ -5087,6 +5088,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.7.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index abe288f..6fa63f0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,6 +66,7 @@ "zustand": "^4.5.7" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.1", "@playwright/test": "^1.57.0", "@tailwindcss/postcss": "^4.1.9", "@testing-library/dom": "^10.4.1",