From 87dd487d488e4cac85c8cfcfefa4780c22190826 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:54:08 +0300 Subject: [PATCH 01/16] refactor: Replace custom migration runner with goose CLI tool in CI and deployment workflows - Install goose CLI tool using go install in CI and deploy workflows - Replace custom migration command (go run cmd/migrate/main.go --action=up) with goose postgres command - Remove working-directory specification from CI migration step - Update commented kubectl exec commands in deploy workflow to use goose instead of custom runner - Simplify migration execution by using standardized goose tool across all environments --- .github/workflows/ci.yml | 6 ++++-- .github/workflows/deploy.yml | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2a0e36..9bc6fc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,13 @@ jobs: working-directory: apps/api-server run: go mod download + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Run database migrations - working-directory: apps/api-server env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: go run cmd/migrate/main.go --action=up + run: goose postgres $DATABASE_URL up migrations - name: Run linter working-directory: apps/api-server diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index afe26c8..e789507 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,10 +170,13 @@ jobs: with: version: 'latest' + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Run database migrations run: | echo "Running database migrations for staging..." - # kubectl exec -it deployment/api-server -- go run cmd/migrate/main.go --action=up + # goose postgres $DATABASE_URL up migrations echo "Staging migrations complete" - name: Deploy to staging cluster @@ -197,10 +200,13 @@ jobs: with: version: 'latest' + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Run database migrations run: | echo "Running database migrations for production..." - # kubectl exec -it deployment/api-server -- go run cmd/migrate/main.go --action=up + # goose postgres $DATABASE_URL up migrations echo "Production migrations complete" - name: Deploy to production cluster From eb5b9092c92fb6c59a01341372e7845e571dc71c Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:54:41 +0300 Subject: [PATCH 02/16] refactor: Remove inline schema creation from RatingPlansRepo in favor of centralized migration system - Remove CREATE TABLE IF NOT EXISTS query for rating_plans table from connect() method - Update comment to reference migration system (make db-migrate) instead of inline schema creation - Add note that rating_plans table is created by 000001_init_rating_plans.up.sql migration - Remove sqlx::query execution and error mapping for schema creation - Keep database connection pool and circuit breaker initialization --- .../src/charging/rating_plans_repo.rs | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/apps/charging-engine/src/charging/rating_plans_repo.rs b/apps/charging-engine/src/charging/rating_plans_repo.rs index b2d447b..54ae2b9 100644 --- a/apps/charging-engine/src/charging/rating_plans_repo.rs +++ b/apps/charging-engine/src/charging/rating_plans_repo.rs @@ -20,7 +20,8 @@ pub struct RatingPlansRepo { } impl RatingPlansRepo { - /// Connect to Postgres using the given DSN and ensure the schema exists. + /// Connect to Postgres using the given DSN. + /// Note: Database schema should be created by running migrations via `make db-migrate` pub async fn connect(database_url: &str) -> ChargingResult { let pool = PgPoolOptions::new() .max_connections(10) @@ -30,30 +31,9 @@ impl RatingPlansRepo { let circuit_breaker = CircuitBreaker::new(5, std::time::Duration::from_secs(60)); - // Idempotent schema for rating plans. api-server's GORM migration also - // creates a compatible `rating_plans` table; the column set here is - // intentionally a strict subset that both services agree on. - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS rating_plans ( - plan_id TEXT PRIMARY KEY, - name TEXT NOT NULL, - data_rate DOUBLE PRECISION NOT NULL, - voice_rate DOUBLE PRECISION NOT NULL, - sms_rate DOUBLE PRECISION NOT NULL, - monthly_fee DOUBLE PRECISION NOT NULL, - data_limit BIGINT NOT NULL, - voice_limit BIGINT NOT NULL, - sms_limit BIGINT NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - "#, - ) - .execute(&pool) - .await - .map_err(|e| crate::errors::ChargingError::DatabaseError(e.to_string()))?; + // Note: Inline schema creation removed - schema managed by centralized migration system + // Run `make db-migrate` to create/update database schema + // The rating_plans table is created by migration 000001_init_rating_plans.up.sql Ok(Self { pool, circuit_breaker }) } From 601c34654ef8b57fad2eedf7500525b1c2097590 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:54:52 +0300 Subject: [PATCH 03/16] feat: Add goose database migrations for rating_plans and esim_profiles tables - Create 00001_init_rating_plans.sql migration with rating_plans table schema - Add plan_id, name, rate fields (data/voice/sms), monthly_fee, and limit fields (data/voice/sms) - Include is_active flag and timestamp fields (created_at, updated_at) - Add indexes on is_active and created_at columns - Create 00002_init_esim_profiles.sql migration with esim_profiles table schema - Add iccid (primary key), eid, imsi, mcc, mnc, profile_ --- migrations/00001_init_rating_plans.sql | 24 +++++++++++++++++++++++ migrations/00002_init_esim_profiles.sql | 26 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 migrations/00001_init_rating_plans.sql create mode 100644 migrations/00002_init_esim_profiles.sql diff --git a/migrations/00001_init_rating_plans.sql b/migrations/00001_init_rating_plans.sql new file mode 100644 index 0000000..b9032d3 --- /dev/null +++ b/migrations/00001_init_rating_plans.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- Create rating_plans table +CREATE TABLE IF NOT EXISTS rating_plans ( + plan_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + data_rate DOUBLE PRECISION NOT NULL, + voice_rate DOUBLE PRECISION NOT NULL, + sms_rate DOUBLE PRECISION NOT NULL, + monthly_fee DOUBLE PRECISION NOT NULL, + data_limit BIGINT NOT NULL, + voice_limit BIGINT NOT NULL, + sms_limit BIGINT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_rating_plans_is_active ON rating_plans(is_active); +CREATE INDEX IF NOT EXISTS idx_rating_plans_created_at ON rating_plans(created_at); + +-- +goose Down +-- Drop rating_plans table +DROP TABLE IF EXISTS rating_plans CASCADE; diff --git a/migrations/00002_init_esim_profiles.sql b/migrations/00002_init_esim_profiles.sql new file mode 100644 index 0000000..22f576f --- /dev/null +++ b/migrations/00002_init_esim_profiles.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Create esim_profiles table +CREATE TABLE IF NOT EXISTS esim_profiles ( + iccid TEXT PRIMARY KEY, + eid TEXT, + imsi TEXT, + mcc TEXT, + mnc TEXT, + profile_type TEXT, + state TEXT, + tenant_id TEXT, + activation_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_esim_profiles_eid ON esim_profiles(eid); +CREATE INDEX IF NOT EXISTS idx_esim_profiles_imsi ON esim_profiles(imsi); +CREATE INDEX IF NOT EXISTS idx_esim_profiles_state ON esim_profiles(state); +CREATE INDEX IF NOT EXISTS idx_esim_profiles_tenant_id ON esim_profiles(tenant_id); +CREATE INDEX IF NOT EXISTS idx_esim_profiles_created_at ON esim_profiles(created_at); + +-- +goose Down +-- Drop esim_profiles table +DROP TABLE IF EXISTS esim_profiles CASCADE; From 85b97c095030b14ff7fb7acf30762988408a0453 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:55:05 +0300 Subject: [PATCH 04/16] deps: Update lib/pq from v1.10.2 to v1.12.3 in api-server --- apps/api-server/go.mod | 1 + apps/api-server/go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/api-server/go.mod b/apps/api-server/go.mod index 979beaf..ec322e9 100644 --- a/apps/api-server/go.mod +++ b/apps/api-server/go.mod @@ -75,6 +75,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/serf v0.10.1 // indirect github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect + github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/microsoft/go-mssqldb v1.9.5 // indirect diff --git a/apps/api-server/go.sum b/apps/api-server/go.sum index 953a594..4416b95 100644 --- a/apps/api-server/go.sum +++ b/apps/api-server/go.sum @@ -319,6 +319,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= From a8381e6b8fca7ae67d760ad7efd7d448791055f0 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:55:13 +0300 Subject: [PATCH 05/16] refactor: Remove inline schema creation from PostgresProfileStore in favor of centralized migration system - Remove AutoMigrate call for profileRow from NewPostgresProfileStore - Update function comment to reference migration system (make db-migrate) instead of inline schema creation - Add note that esim_profiles table is created by migration system - Remove error handling for AutoMigrate execution --- apps/carrier-connector/internal/repository/postgres.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/carrier-connector/internal/repository/postgres.go b/apps/carrier-connector/internal/repository/postgres.go index 526f60d..51acfa3 100644 --- a/apps/carrier-connector/internal/repository/postgres.go +++ b/apps/carrier-connector/internal/repository/postgres.go @@ -34,8 +34,8 @@ type PostgresProfileStore struct { db *gorm.DB } -// NewPostgresProfileStore connects to Postgres using the given DSN, runs the -// profile schema migration, and returns a repository ready to use. +// NewPostgresProfileStore connects to Postgres using the given DSN and returns a repository ready to use. +// Note: Database schema should be created by running migrations via `make db-migrate` func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) { if dsn == "" { return nil, errors.New("postgres DSN is empty") @@ -46,9 +46,8 @@ func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) { if err != nil { return nil, fmt.Errorf("open postgres: %w", err) } - if err := db.AutoMigrate(&profileRow{}); err != nil { - return nil, fmt.Errorf("migrate esim_profiles: %w", err) - } + // Note: AutoMigrate removed - schema managed by migration system + // Run `make db-migrate` to create/update database schema return &PostgresProfileStore{db: db}, nil } From 76d76614434d232aa29c82b18860aaa97ad388dc Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:57:41 +0300 Subject: [PATCH 06/16] feat: Add automatic Goose migration execution on startup in carrier-connector PostgresProfileStore - Add os/exec import for running external commands - Implement runMigrations function to execute goose postgres migrations - Call runMigrations in NewPostgresProfileStore before returning store instance - Update function comment to reflect automatic migration execution on startup - Add error handling and output capture for migration failures --- .../internal/repository/postgres.go | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/carrier-connector/internal/repository/postgres.go b/apps/carrier-connector/internal/repository/postgres.go index 51acfa3..ebe92c3 100644 --- a/apps/carrier-connector/internal/repository/postgres.go +++ b/apps/carrier-connector/internal/repository/postgres.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os/exec" "time" "gorm.io/driver/postgres" @@ -34,8 +35,9 @@ type PostgresProfileStore struct { db *gorm.DB } -// NewPostgresProfileStore connects to Postgres using the given DSN and returns a repository ready to use. -// Note: Database schema should be created by running migrations via `make db-migrate` +// NewPostgresProfileStore connects to Postgres using the given DSN and runs migrations. +// Note: Database schema is managed by centralized migration system using Goose. +// Migrations are automatically run on startup. func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) { if dsn == "" { return nil, errors.New("postgres DSN is empty") @@ -46,11 +48,25 @@ func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) { if err != nil { return nil, fmt.Errorf("open postgres: %w", err) } - // Note: AutoMigrate removed - schema managed by migration system - // Run `make db-migrate` to create/update database schema + + // Run Goose migrations on startup + if err := runMigrations(dsn); err != nil { + return nil, fmt.Errorf("migration failed: %w", err) + } + return &PostgresProfileStore{db: db}, nil } +// runMigrations executes Goose migrations using the goose binary +func runMigrations(dsn string) error { + cmd := exec.Command("goose", "postgres", dsn, "up", "migrations") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("goose migration failed: %w, output: %s", err, string(output)) + } + return nil +} + // Create inserts a profile row. func (s *PostgresProfileStore) Create(ctx context.Context, p *Profile) error { row := toRow(p) From 0a02fe4e033ab01f091ea2886f014d8b414a8b27 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:57:50 +0300 Subject: [PATCH 07/16] feat: Replace custom migration runner with goose CLI and add migration management commands to Makefile - Replace custom Go migration runner (go run cmd/migrate/main.go) with goose postgres command in db-migrate target - Add db-migrate-down target for rolling back migrations - Add db-migrate-status target to show current migration status - Add db-create-migration target for creating new migration files with NAME parameter validation - Add db-migrate-rust target for Rust service migrations (uses same --- Makefile | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9636a3c..ec135cc 100644 --- a/Makefile +++ b/Makefile @@ -181,11 +181,30 @@ db-setup: ## Set up PostgreSQL, MongoDB and Redis @bash scripts/setup-redis.sh @echo "$(GREEN)Database setup complete!$(RESET)" -db-migrate: ## Run database migrations +db-migrate: ## Run database migrations (Go and Rust) @echo "$(CYAN)Running database migrations...$(RESET)" - @cd apps/api-server && go run cmd/migrate/main.go + @goose postgres $(DATABASE_URL) up migrations @echo "$(GREEN)Migrations complete!$(RESET)" +db-migrate-down: ## Rollback database migrations + @echo "$(CYAN)Rolling back database migrations...$(RESET)" + @goose postgres $(DATABASE_URL) down migrations + @echo "$(GREEN)Rollback complete!$(RESET)" + +db-migrate-status: ## Show migration status + @echo "$(CYAN)Migration status:$(RESET)" + @goose postgres $(DATABASE_URL) status migrations + +db-create-migration: ## Create a new migration file (usage: make db-create-migration NAME=migration_name) + @echo "$(CYAN)Creating new migration...$(RESET)" + @if [ -z "$(NAME)" ]; then echo "Error: NAME parameter is required. Usage: make db-create-migration NAME=migration_name"; exit 1; fi + @goose create $(NAME) sql + +db-migrate-rust: ## Run migrations for Rust services (uses goose binary) + @echo "$(CYAN)Running migrations for Rust services...$(RESET)" + @goose postgres $(DATABASE_URL) up migrations + @echo "$(GREEN)Rust migrations complete!$(RESET)" + # Free5GC integration free5gc-setup: ## Set up free5GC configuration files @echo "$(CYAN)Setting up free5GC configuration...$(RESET)" From 85012f380f034a6ee0d242329234b5a52fb008ae Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 02:58:48 +0300 Subject: [PATCH 08/16] feat: Add PostgreSQL service and database migrations to Rust component test jobs in CI workflow - Upgrade PostgreSQL service from version 15 to 18 in api-server-test job - Add PostgreSQL 18 service configuration to charging-engine-test and packet-gateway-test jobs - Configure database health checks with pg_isready command for all Postgres services - Add goose installation step to charging-engine-test and packet-gateway-test jobs - Add database migration execution step before running tests in Rust components --- .github/workflows/ci.yml | 46 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bc6fc4..b05af06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:15 + image: postgres:18 env: POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password @@ -110,6 +110,20 @@ jobs: charging-engine-test: name: Charging Engine Tests runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout code uses: actions/checkout@v4 @@ -134,6 +148,14 @@ jobs: working-directory: apps/charging-engine run: cargo fetch + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + + - name: Run database migrations + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable + run: goose postgres $DATABASE_URL up migrations + - name: Run tests working-directory: apps/charging-engine run: cargo test --release || true @@ -145,6 +167,20 @@ jobs: packet-gateway-test: name: Packet Gateway Tests runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout code uses: actions/checkout@v4 @@ -169,6 +205,14 @@ jobs: working-directory: apps/packet-gateway run: cargo fetch + - name: Install goose + run: go install github.com/pressly/goose/v3/cmd/goose@latest + + - name: Run database migrations + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable + run: goose postgres $DATABASE_URL up migrations + - name: Run tests working-directory: apps/packet-gateway run: cargo test --release || true From 69241334aa6af664403310bc61e476e3761c9111 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:00:28 +0300 Subject: [PATCH 09/16] feat: Add automatic Goose migration execution on startup in charging-engine RatingPlansRepo - Add std::process::Command import for running external commands - Implement run_migrations function to execute goose postgres migrations - Call run_migrations in RatingPlansRepo::connect before returning repository instance - Update function comments to reflect automatic migration execution on startup - Add error handling and output capture for migration failures - Add tracing log for successful migration --- .../src/charging/rating_plans_repo.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/charging-engine/src/charging/rating_plans_repo.rs b/apps/charging-engine/src/charging/rating_plans_repo.rs index 54ae2b9..1982fc2 100644 --- a/apps/charging-engine/src/charging/rating_plans_repo.rs +++ b/apps/charging-engine/src/charging/rating_plans_repo.rs @@ -4,6 +4,7 @@ //! services read a single source of truth. use std::collections::HashMap; +use std::process::Command; use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::Row; @@ -20,8 +21,9 @@ pub struct RatingPlansRepo { } impl RatingPlansRepo { - /// Connect to Postgres using the given DSN. - /// Note: Database schema should be created by running migrations via `make db-migrate` + /// Connect to Postgres using the given DSN and run migrations. + /// Note: Database schema is managed by centralized migration system using Goose. + /// Migrations are automatically run on startup. pub async fn connect(database_url: &str) -> ChargingResult { let pool = PgPoolOptions::new() .max_connections(10) @@ -31,9 +33,10 @@ impl RatingPlansRepo { let circuit_breaker = CircuitBreaker::new(5, std::time::Duration::from_secs(60)); - // Note: Inline schema creation removed - schema managed by centralized migration system - // Run `make db-migrate` to create/update database schema - // The rating_plans table is created by migration 000001_init_rating_plans.up.sql + // Run Goose migrations on startup + if let Err(e) = run_migrations(database_url).await { + return Err(crate::errors::ChargingError::InternalError(format!("Migration failed: {}", e))); + } Ok(Self { pool, circuit_breaker }) } @@ -230,3 +233,19 @@ fn row_to_plan(row: sqlx::postgres::PgRow) -> RatingPlan { sms_limit: row.get::("sms_limit") as u64, } } + +/// Run Goose migrations by calling the goose binary. +/// This ensures both Go and Rust services use the same migration system. +async fn run_migrations(database_url: &str) -> Result<(), Box> { + let output = Command::new("goose") + .args(["postgres", database_url, "up", "migrations"]) + .output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Goose migration failed: {}", stderr).into()); + } + + tracing::info!("Database migrations completed successfully"); + Ok(()) +} From 96eacf8e77b3316c7f5751581f8a0fa738d62147 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:11:46 +0300 Subject: [PATCH 10/16] fix: Add goose to PATH and correct migrations directory path in CI workflow - Add explicit PATH configuration step to ensure goose binary is accessible after installation - Fix migrations directory path from 'migrations' to './migrations' in all test jobs - Apply changes to api-server-test, charging-engine-test, and packet-gateway-test jobs --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05af06..6247e4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,10 +43,13 @@ jobs: - name: Install goose run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Add goose to PATH + run: echo "$HOME/go/bin" >> $GITHUB_PATH + - name: Run database migrations env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up migrations + run: goose postgres $DATABASE_URL up ./migrations - name: Run linter working-directory: apps/api-server @@ -151,10 +154,13 @@ jobs: - name: Install goose run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Add goose to PATH + run: echo "$HOME/go/bin" >> $GITHUB_PATH + - name: Run database migrations env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up migrations + run: goose postgres $DATABASE_URL up ./migrations - name: Run tests working-directory: apps/charging-engine @@ -208,10 +214,13 @@ jobs: - name: Install goose run: go install github.com/pressly/goose/v3/cmd/goose@latest + - name: Add goose to PATH + run: echo "$HOME/go/bin" >> $GITHUB_PATH + - name: Run database migrations env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up migrations + run: goose postgres $DATABASE_URL up ./migrations - name: Run tests working-directory: apps/packet-gateway From 28422fafc1f10d0e6c773c596d689259dee42086 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:18:11 +0300 Subject: [PATCH 11/16] fix: Standardize migrations directory path and add explicit working-directory in CI workflow - Change migrations path from './migrations' to 'migrations' in all test jobs - Add explicit working-directory: . to migration steps for clarity - Apply changes to api-server-test, charging-engine-test, and packet-gateway-test jobs --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6247e4e..6995149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,10 @@ jobs: run: echo "$HOME/go/bin" >> $GITHUB_PATH - name: Run database migrations + working-directory: . env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up ./migrations + run: goose postgres $DATABASE_URL up migrations - name: Run linter working-directory: apps/api-server @@ -158,9 +159,10 @@ jobs: run: echo "$HOME/go/bin" >> $GITHUB_PATH - name: Run database migrations + working-directory: . env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up ./migrations + run: goose postgres $DATABASE_URL up migrations - name: Run tests working-directory: apps/charging-engine @@ -218,9 +220,10 @@ jobs: run: echo "$HOME/go/bin" >> $GITHUB_PATH - name: Run database migrations + working-directory: . env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable - run: goose postgres $DATABASE_URL up ./migrations + run: goose postgres $DATABASE_URL up migrations - name: Run tests working-directory: apps/packet-gateway From e6450f15ad58352080828d59ebe5422284edec36 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:25:28 +0300 Subject: [PATCH 12/16] feat: Add comprehensive database schema migrations for all platform tables - Add subscribers table with IMSI, MSISDN, authentication keys, and eSIM profile fields - Add service_plans table for data/voice/SMS limits and QoS parameters (ARPA, AMBR) - Add sessions table for tracking PDU sessions with QoS and usage metrics - Add usage_records table for billing and charging data - Add users table with role-based access control fields - Add roles, permissions, and role_permissions junction table for RBAC - Add auth --- migrations/00003_init_subscribers.sql | 38 +++++++++++++++ migrations/00004_init_service_plans.sql | 28 +++++++++++ migrations/00005_init_sessions.sql | 37 +++++++++++++++ migrations/00006_init_usage_records.sql | 27 +++++++++++ migrations/00007_init_users.sql | 26 +++++++++++ migrations/00008_init_roles_permissions.sql | 37 +++++++++++++++ .../00009_init_auth_sessions_api_keys.sql | 40 ++++++++++++++++ migrations/00010_init_plugins.sql | 30 ++++++++++++ migrations/00011_init_automations.sql | 46 +++++++++++++++++++ migrations/00012_init_config_entries.sql | 23 ++++++++++ migrations/00013_init_deployment_records.sql | 28 +++++++++++ .../00014_init_chaos_experiment_records.sql | 28 +++++++++++ migrations/00015_init_imsi_allocations.sql | 14 ++++++ migrations/00016_init_payment_methods.sql | 23 ++++++++++ migrations/00017_init_transactions.sql | 25 ++++++++++ .../00018_init_notifications_alerts.sql | 35 ++++++++++++++ 16 files changed, 485 insertions(+) create mode 100644 migrations/00003_init_subscribers.sql create mode 100644 migrations/00004_init_service_plans.sql create mode 100644 migrations/00005_init_sessions.sql create mode 100644 migrations/00006_init_usage_records.sql create mode 100644 migrations/00007_init_users.sql create mode 100644 migrations/00008_init_roles_permissions.sql create mode 100644 migrations/00009_init_auth_sessions_api_keys.sql create mode 100644 migrations/00010_init_plugins.sql create mode 100644 migrations/00011_init_automations.sql create mode 100644 migrations/00012_init_config_entries.sql create mode 100644 migrations/00013_init_deployment_records.sql create mode 100644 migrations/00014_init_chaos_experiment_records.sql create mode 100644 migrations/00015_init_imsi_allocations.sql create mode 100644 migrations/00016_init_payment_methods.sql create mode 100644 migrations/00017_init_transactions.sql create mode 100644 migrations/00018_init_notifications_alerts.sql diff --git a/migrations/00003_init_subscribers.sql b/migrations/00003_init_subscribers.sql new file mode 100644 index 0000000..a4c7923 --- /dev/null +++ b/migrations/00003_init_subscribers.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- Create subscribers table +CREATE TABLE IF NOT EXISTS subscribers ( + id SERIAL PRIMARY KEY, + imsi TEXT NOT NULL UNIQUE, + msisdn TEXT UNIQUE, + imei TEXT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT UNIQUE, + organization_id TEXT, + status TEXT NOT NULL DEFAULT 'active', + plan_id INTEGER, + balance DOUBLE PRECISION NOT NULL DEFAULT 0, + auth_key TEXT NOT NULL, + opc TEXT NOT NULL, + serving_plmn_mcc TEXT NOT NULL, + serving_plmn_mnc TEXT NOT NULL, + euicc_id TEXT, + profile_id TEXT, + profile_status TEXT NOT NULL DEFAULT 'inactive', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_subscribers_imsi ON subscribers(imsi); +CREATE INDEX IF NOT EXISTS idx_subscribers_msisdn ON subscribers(msisdn); +CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email); +CREATE INDEX IF NOT EXISTS idx_subscribers_status ON subscribers(status); +CREATE INDEX IF NOT EXISTS idx_subscribers_organization_id ON subscribers(organization_id); +CREATE INDEX IF NOT EXISTS idx_subscribers_plan_id ON subscribers(plan_id); +CREATE INDEX IF NOT EXISTS idx_subscribers_deleted_at ON subscribers(deleted_at); + +-- +goose Down +-- Drop subscribers table +DROP TABLE IF EXISTS subscribers CASCADE; diff --git a/migrations/00004_init_service_plans.sql b/migrations/00004_init_service_plans.sql new file mode 100644 index 0000000..6ab7275 --- /dev/null +++ b/migrations/00004_init_service_plans.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Create service_plans table +CREATE TABLE IF NOT EXISTS service_plans ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + data_limit BIGINT, + voice_limit INTEGER, + sms_limit INTEGER, + monthly_fee DOUBLE PRECISION, + data_rate DOUBLE PRECISION, + voice_rate DOUBLE PRECISION, + sms_rate DOUBLE PRECISION, + arpa INTEGER, + ambr_uplink INTEGER, + ambr_downlink INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_service_plans_name ON service_plans(name); +CREATE INDEX IF NOT EXISTS idx_service_plans_deleted_at ON service_plans(deleted_at); + +-- +goose Down +-- Drop service_plans table +DROP TABLE IF EXISTS service_plans CASCADE; diff --git a/migrations/00005_init_sessions.sql b/migrations/00005_init_sessions.sql new file mode 100644 index 0000000..730860e --- /dev/null +++ b/migrations/00005_init_sessions.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- Create sessions table +CREATE TABLE IF NOT EXISTS sessions ( + id SERIAL PRIMARY KEY, + subscriber_id INTEGER NOT NULL, + session_id TEXT NOT NULL UNIQUE, + pdu_address TEXT, + dnn TEXT, + snssai_sst SMALLINT, + snssai_sd INTEGER, + qos_qci SMALLINT, + qos_arpa INTEGER, + qos_mbr_uplink INTEGER, + qos_mbr_downlink INTEGER, + qos_gbr_uplink INTEGER, + qos_gbr_downlink INTEGER, + qos_priority SMALLINT, + status TEXT NOT NULL DEFAULT 'active', + start_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + end_time TIMESTAMPTZ, + data_used BIGINT NOT NULL DEFAULT 0, + voice_used INTEGER NOT NULL DEFAULT 0, + sms_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_sessions_subscriber_id ON sessions(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status); +CREATE INDEX IF NOT EXISTS idx_sessions_deleted_at ON sessions(deleted_at); + +-- +goose Down +-- Drop sessions table +DROP TABLE IF EXISTS sessions CASCADE; diff --git a/migrations/00006_init_usage_records.sql b/migrations/00006_init_usage_records.sql new file mode 100644 index 0000000..d945d2b --- /dev/null +++ b/migrations/00006_init_usage_records.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Create usage_records table +CREATE TABLE IF NOT EXISTS usage_records ( + id SERIAL PRIMARY KEY, + subscriber_id INTEGER NOT NULL, + session_id INTEGER, + usage_type TEXT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + volume BIGINT NOT NULL, + rate DOUBLE PRECISION, + cost DOUBLE PRECISION, + billing_cycle TEXT, + invoice_id INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_usage_records_subscriber_id ON usage_records(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_usage_records_session_id ON usage_records(session_id); +CREATE INDEX IF NOT EXISTS idx_usage_records_usage_type ON usage_records(usage_type); +CREATE INDEX IF NOT EXISTS idx_usage_records_billing_cycle ON usage_records(billing_cycle); + +-- +goose Down +-- Drop usage_records table +DROP TABLE IF EXISTS usage_records CASCADE; diff --git a/migrations/00007_init_users.sql b/migrations/00007_init_users.sql new file mode 100644 index 0000000..ae0f6f8 --- /dev/null +++ b/migrations/00007_init_users.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + is_active BOOLEAN NOT NULL DEFAULT true, + last_login TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at); + +-- +goose Down +-- Drop users table +DROP TABLE IF EXISTS users CASCADE; diff --git a/migrations/00008_init_roles_permissions.sql b/migrations/00008_init_roles_permissions.sql new file mode 100644 index 0000000..5f4897f --- /dev/null +++ b/migrations/00008_init_roles_permissions.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- Create roles table +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create permissions table +CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + resource TEXT NOT NULL, + action TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create role_permissions junction table +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); + +-- +goose Down +-- Drop role_permissions, permissions, and roles tables +DROP TABLE IF EXISTS role_permissions CASCADE; +DROP TABLE IF EXISTS permissions CASCADE; +DROP TABLE IF EXISTS roles CASCADE; diff --git a/migrations/00009_init_auth_sessions_api_keys.sql b/migrations/00009_init_auth_sessions_api_keys.sql new file mode 100644 index 0000000..397fbe0 --- /dev/null +++ b/migrations/00009_init_auth_sessions_api_keys.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- Create auth_sessions table +CREATE TABLE IF NOT EXISTS auth_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + ip_address TEXT, + user_agent TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create api_keys table +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + secret TEXT NOT NULL, + permissions JSONB, + is_active BOOLEAN NOT NULL DEFAULT true, + expires_at TIMESTAMPTZ, + last_used TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id ON auth_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token); +CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key); +CREATE INDEX IF NOT EXISTS idx_api_keys_is_active ON api_keys(is_active); + +-- +goose Down +-- Drop auth_sessions and api_keys tables +DROP TABLE IF EXISTS auth_sessions CASCADE; +DROP TABLE IF EXISTS api_keys CASCADE; diff --git a/migrations/00010_init_plugins.sql b/migrations/00010_init_plugins.sql new file mode 100644 index 0000000..ac2b2d2 --- /dev/null +++ b/migrations/00010_init_plugins.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- Create plugins table +CREATE TABLE IF NOT EXISTS plugins ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + version TEXT NOT NULL, + description TEXT, + author TEXT, + type TEXT, + category TEXT, + license TEXT, + homepage TEXT, + repository TEXT, + enabled BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'installed', + config JSONB, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name); +CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled); +CREATE INDEX IF NOT EXISTS idx_plugins_deleted_at ON plugins(deleted_at); + +-- +goose Down +-- Drop plugins table +DROP TABLE IF EXISTS plugins CASCADE; diff --git a/migrations/00011_init_automations.sql b/migrations/00011_init_automations.sql new file mode 100644 index 0000000..d379f14 --- /dev/null +++ b/migrations/00011_init_automations.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- Create automations table +CREATE TABLE IF NOT EXISTS automations ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + type TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + schedule_type TEXT, + schedule_cron TEXT, + schedule_interval_sec INTEGER, + timezone TEXT NOT NULL DEFAULT 'UTC', + definition JSONB, + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- Create automation_runs table +CREATE TABLE IF NOT EXISTS automation_runs ( + id SERIAL PRIMARY KEY, + automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, + status TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + duration_ms BIGINT NOT NULL DEFAULT 0, + output TEXT, + error TEXT, + details JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_automations_name ON automations(name); +CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations(enabled); +CREATE INDEX IF NOT EXISTS idx_automations_deleted_at ON automations(deleted_at); +CREATE INDEX IF NOT EXISTS idx_automation_runs_automation_id ON automation_runs(automation_id); +CREATE INDEX IF NOT EXISTS idx_automation_runs_status ON automation_runs(status); +CREATE INDEX IF NOT EXISTS idx_automation_runs_started_at ON automation_runs(started_at); + +-- +goose Down +-- Drop automation_runs and automations tables +DROP TABLE IF EXISTS automation_runs CASCADE; +DROP TABLE IF EXISTS automations CASCADE; diff --git a/migrations/00012_init_config_entries.sql b/migrations/00012_init_config_entries.sql new file mode 100644 index 0000000..651db83 --- /dev/null +++ b/migrations/00012_init_config_entries.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Create config_entries table +CREATE TABLE IF NOT EXISTS config_entries ( + id SERIAL PRIMARY KEY, + section TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + type TEXT, + sensitive BOOLEAN NOT NULL DEFAULT false, + description TEXT, + updated_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(section, key) +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_config_entries_section ON config_entries(section); +CREATE INDEX IF NOT EXISTS idx_config_entries_key ON config_entries(key); + +-- +goose Down +-- Drop config_entries table +DROP TABLE IF EXISTS config_entries CASCADE; diff --git a/migrations/00013_init_deployment_records.sql b/migrations/00013_init_deployment_records.sql new file mode 100644 index 0000000..ca222c3 --- /dev/null +++ b/migrations/00013_init_deployment_records.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Create deployment_records table +CREATE TABLE IF NOT EXISTS deployment_records ( + id SERIAL PRIMARY KEY, + service TEXT NOT NULL, + version TEXT NOT NULL, + environment TEXT, + status TEXT NOT NULL, + strategy TEXT, + replicas INTEGER NOT NULL DEFAULT 1, + triggered_by TEXT, + reason TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + rollback_to TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_deployment_records_service ON deployment_records(service); +CREATE INDEX IF NOT EXISTS idx_deployment_records_environment ON deployment_records(environment); +CREATE INDEX IF NOT EXISTS idx_deployment_records_status ON deployment_records(status); +CREATE INDEX IF NOT EXISTS idx_deployment_records_created_at ON deployment_records(created_at); + +-- +goose Down +-- Drop deployment_records table +DROP TABLE IF EXISTS deployment_records CASCADE; diff --git a/migrations/00014_init_chaos_experiment_records.sql b/migrations/00014_init_chaos_experiment_records.sql new file mode 100644 index 0000000..c46e92f --- /dev/null +++ b/migrations/00014_init_chaos_experiment_records.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Create chaos_experiment_records table +CREATE TABLE IF NOT EXISTS chaos_experiment_records ( + id SERIAL PRIMARY KEY, + external_id TEXT UNIQUE, + name TEXT, + type TEXT, + target TEXT, + status TEXT, + duration_ms BIGINT NOT NULL DEFAULT 0, + probability DOUBLE PRECISION, + amount INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_chaos_experiment_records_external_id ON chaos_experiment_records(external_id); +CREATE INDEX IF NOT EXISTS idx_chaos_experiment_records_status ON chaos_experiment_records(status); +CREATE INDEX IF NOT EXISTS idx_chaos_experiment_records_target ON chaos_experiment_records(target); +CREATE INDEX IF NOT EXISTS idx_chaos_experiment_records_started_at ON chaos_experiment_records(started_at); + +-- +goose Down +-- Drop chaos_experiment_records table +DROP TABLE IF EXISTS chaos_experiment_records CASCADE; diff --git a/migrations/00015_init_imsi_allocations.sql b/migrations/00015_init_imsi_allocations.sql new file mode 100644 index 0000000..b3ce7c5 --- /dev/null +++ b/migrations/00015_init_imsi_allocations.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Create imsi_allocations table +CREATE TABLE IF NOT EXISTS imsi_allocations ( + id SERIAL PRIMARY KEY, + last_imsi BIGINT NOT NULL DEFAULT 0, + min_imsi BIGINT NOT NULL DEFAULT 1, + max_imsi BIGINT NOT NULL DEFAULT 999999999, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +goose Down +-- Drop imsi_allocations table +DROP TABLE IF EXISTS imsi_allocations CASCADE; diff --git a/migrations/00016_init_payment_methods.sql b/migrations/00016_init_payment_methods.sql new file mode 100644 index 0000000..94ec0b7 --- /dev/null +++ b/migrations/00016_init_payment_methods.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Create payment_methods table +CREATE TABLE IF NOT EXISTS payment_methods ( + id TEXT PRIMARY KEY, + subscriber_id INTEGER NOT NULL, + gateway_id TEXT, + type TEXT NOT NULL, + customer_id TEXT, + last4 TEXT, + brand TEXT, + expiry_month INTEGER, + expiry_year INTEGER, + is_default BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_payment_methods_subscriber_id ON payment_methods(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_payment_methods_gateway_id ON payment_methods(gateway_id); + +-- +goose Down +-- Drop payment_methods table +DROP TABLE IF EXISTS payment_methods CASCADE; diff --git a/migrations/00017_init_transactions.sql b/migrations/00017_init_transactions.sql new file mode 100644 index 0000000..f230267 --- /dev/null +++ b/migrations/00017_init_transactions.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Create transactions table +CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + subscriber_id INTEGER NOT NULL, + transaction_id TEXT NOT NULL, + type TEXT NOT NULL, + amount DOUBLE PRECISION NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + description TEXT, + parent_id INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_transactions_subscriber_id ON transactions(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_transactions_transaction_id ON transactions(transaction_id); +CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status); +CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at); + +-- +goose Down +-- Drop transactions table +DROP TABLE IF EXISTS transactions CASCADE; diff --git a/migrations/00018_init_notifications_alerts.sql b/migrations/00018_init_notifications_alerts.sql new file mode 100644 index 0000000..3a6da60 --- /dev/null +++ b/migrations/00018_init_notifications_alerts.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- Create notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + subscriber_id INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create alerts table +CREATE TABLE IF NOT EXISTS alerts ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, + severity TEXT NOT NULL, + message TEXT NOT NULL, + subscriber_id INTEGER, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved BOOLEAN NOT NULL DEFAULT false +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_notifications_subscriber_id ON notifications(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read); +CREATE INDEX IF NOT EXISTS idx_alerts_subscriber_id ON alerts(subscriber_id); +CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(type); +CREATE INDEX IF NOT EXISTS idx_alerts_severity ON alerts(severity); +CREATE INDEX IF NOT EXISTS idx_alerts_resolved ON alerts(resolved); + +-- +goose Down +-- Drop notifications and alerts tables +DROP TABLE IF EXISTS notifications CASCADE; +DROP TABLE IF EXISTS alerts CASCADE; From 71353fc2373df775ff4c288f811602def069f984 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:31:00 +0300 Subject: [PATCH 13/16] feat: Replace GORM AutoMigrate with Goose CLI migrations in api-server database initialization - Add os/exec import for running external commands - Replace AutoMigrate function with runMigrations function using goose postgres command - Call runMigrations in NewDatabase before initializing IMSI allocation - Remove PoolStats function and GORM-based schema migration code - Reorganize database methods with section comments (Subscriber CRUD, Payment methods, Transactions, Additional CRUD, IMSI allocation) - --- apps/api-server/internal/database/database.go | 148 +++++++----------- 1 file changed, 53 insertions(+), 95 deletions(-) diff --git a/apps/api-server/internal/database/database.go b/apps/api-server/internal/database/database.go index da434ab..098ee50 100644 --- a/apps/api-server/internal/database/database.go +++ b/apps/api-server/internal/database/database.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "os/exec" "time" "gorm.io/driver/postgres" @@ -31,36 +32,28 @@ func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", cfg.Host, cfg.Username, cfg.Password, cfg.Database, cfg.Port, cfg.SSLMode) - // Configure GORM logger gormLogger := logger.Default.LogMode(logger.Info) - - db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ - Logger: gormLogger, - }) + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormLogger}) if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } - // Get underlying SQL DB to configure connection pool sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) } - // Configure connection pool for optimal performance - sqlDB.SetMaxIdleConns(25) // Minimum idle connections - sqlDB.SetMaxOpenConns(100) // Maximum open connections - sqlDB.SetConnMaxLifetime(time.Hour) // Max connection lifetime - sqlDB.SetConnMaxIdleTime(30 * time.Minute) // Max idle time before closing + sqlDB.SetMaxIdleConns(25) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + sqlDB.SetConnMaxIdleTime(30 * time.Minute) database := &Database{DB: db} - // Auto-migrate schemas - if err := database.AutoMigrate(); err != nil { - return nil, fmt.Errorf("failed to auto-migrate: %w", err) + if err := runMigrations(dsn); err != nil { + return nil, fmt.Errorf("failed to run migrations: %w", err) } - // Initialize IMSI allocation if err := database.InitializeIMSIAllocation(); err != nil { return nil, fmt.Errorf("failed to initialize IMSI allocation: %w", err) } @@ -68,20 +61,14 @@ func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { return database, nil } -func (d *Database) AutoMigrate() error { - return d.DB.AutoMigrate( - &models.Subscriber{}, - &models.ServicePlan{}, - &models.Session{}, - &models.UsageRecord{}, - &IMSIAllocation{}, - &models.Plugin{}, - &models.Automation{}, - &models.AutomationRun{}, - &models.ConfigEntry{}, - &models.DeploymentRecord{}, - &models.ChaosExperimentRecord{}, - ) +func runMigrations(dsn string) error { + cmd := exec.Command("goose", "postgres", dsn, "up", "migrations") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("goose migration failed: %w, output: %s", err, string(output)) + } + log.Printf("Database migrations completed successfully") + return nil } func (d *Database) InitializeIMSIAllocation() error { @@ -89,17 +76,10 @@ func (d *Database) InitializeIMSIAllocation() error { result := d.DB.First(&allocation) if result.Error == gorm.ErrRecordNotFound { - // Create initial allocation record - allocation = IMSIAllocation{ - LastIMSI: 0, - MinIMSI: 1, - MaxIMSI: 999999999, // 9-digit subscriber numbers - } - + allocation = IMSIAllocation{LastIMSI: 0, MinIMSI: 1, MaxIMSI: 999999999} if err := d.DB.Create(&allocation).Error; err != nil { return fmt.Errorf("failed to create IMSI allocation: %w", err) } - log.Printf("Created IMSI allocation record") } else if result.Error != nil { return fmt.Errorf("failed to query IMSI allocation: %w", result.Error) @@ -116,27 +96,6 @@ func (d *Database) Close() error { return sqlDB.Close() } -// PoolStats returns current connection pool statistics -func (d *Database) PoolStats() (map[string]any, error) { - sqlDB, err := d.DB.DB() - if err != nil { - return nil, err - } - stats := sqlDB.Stats() - return map[string]any{ - "max_open_connections": stats.MaxOpenConnections, - "open_connections": stats.OpenConnections, - "in_use": stats.InUse, - "idle": stats.Idle, - "wait_count": stats.WaitCount, - "wait_duration": stats.WaitDuration.String(), - "max_idle_closed": stats.MaxIdleClosed, - "max_idle_time_closed": stats.MaxIdleTimeClosed, - "max_lifetime_closed": stats.MaxLifetimeClosed, - }, nil -} - -// Ping verifies database connectivity func (d *Database) Ping(ctx context.Context) error { sqlDB, err := d.DB.DB() if err != nil { @@ -145,6 +104,7 @@ func (d *Database) Ping(ctx context.Context) error { return sqlDB.PingContext(ctx) } +// Subscriber CRUD func (d *Database) CreateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { return d.DB.WithContext(ctx).Create(subscriber).Error } @@ -176,8 +136,6 @@ func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequ var total int64 query := d.DB.WithContext(ctx).Model(&models.Subscriber{}) - - // Apply filters if req.Status != "" { query = query.Where("status = ?", req.Status) } @@ -189,31 +147,15 @@ func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequ "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%") } - // Count total records if err := query.Count(&total).Error; err != nil { return nil, 0, err } - // Apply pagination and ordering offset := (req.Page - 1) * req.PageSize err := query.Preload("Plan").Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&subscribers).Error - return subscribers, total, err } -func (d *Database) GetIMSIAllocation(ctx context.Context) (*IMSIAllocation, error) { - var allocation IMSIAllocation - err := d.DB.WithContext(ctx).First(&allocation).Error - if err != nil { - return nil, err - } - return &allocation, nil -} - -func (d *Database) UpdateIMSIAllocation(ctx context.Context, allocation *IMSIAllocation) error { - return d.DB.WithContext(ctx).Save(allocation).Error -} - func (d *Database) GetActiveSessionsByIMSI(ctx context.Context, imsi models.IMSI) ([]models.Session, error) { var sessions []models.Session err := d.DB.WithContext(ctx).Where("subscriber_id = ? AND status = ?", imsi, "active").Find(&sessions).Error @@ -224,15 +166,7 @@ func (d *Database) UpdateSession(ctx context.Context, session *models.Session) e return d.DB.WithContext(ctx).Save(session).Error } -func (d *Database) CreateTransaction(ctx context.Context, transaction *models.Transaction) error { - return d.DB.WithContext(ctx).Create(transaction).Error -} - -func (d *Database) UpdateSubscriberBalance(ctx context.Context, subscriberID uint, amount float64) error { - return d.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("id = ?", subscriberID). - UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error -} - +// Payment methods func (d *Database) CreatePaymentMethod(ctx context.Context, pm *models.PaymentMethod) error { return d.DB.WithContext(ctx).Create(pm).Error } @@ -246,16 +180,17 @@ func (d *Database) GetPaymentMethod(ctx context.Context, id string) (*models.Pay return &pm, nil } -func (d *Database) DeletePaymentMethod(ctx context.Context, id string) error { - return d.DB.WithContext(ctx).Where("id = ?", id).Delete(&models.PaymentMethod{}).Error -} - func (d *Database) ListPaymentMethods(ctx context.Context, subscriberID uint) ([]models.PaymentMethod, error) { var methods []models.PaymentMethod err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Find(&methods).Error return methods, err } +// Transactions +func (d *Database) CreateTransaction(ctx context.Context, transaction *models.Transaction) error { + return d.DB.WithContext(ctx).Create(transaction).Error +} + func (d *Database) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) { var tx models.Transaction err := d.DB.WithContext(ctx).Where("transaction_id = ?", transactionID).First(&tx).Error @@ -293,6 +228,16 @@ func (d *Database) ListTransactions(ctx context.Context, subscriberID uint) ([]m return transactions, err } +// Additional CRUD +func (d *Database) UpdateSubscriberBalance(ctx context.Context, subscriberID uint, amount float64) error { + return d.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("id = ?", subscriberID). + UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error +} + +func (d *Database) DeletePaymentMethod(ctx context.Context, id string) error { + return d.DB.WithContext(ctx).Where("id = ?", id).Delete(&models.PaymentMethod{}).Error +} + func (d *Database) CreateAlert(ctx context.Context, alert *models.Alert) error { return d.DB.WithContext(ctx).Create(alert).Error } @@ -301,11 +246,24 @@ func (d *Database) CreateNotification(ctx context.Context, notification *models. return d.DB.WithContext(ctx).Create(notification).Error } -// Request types for database operations +// IMSI allocation +func (d *Database) GetIMSIAllocation(ctx context.Context) (*IMSIAllocation, error) { + var allocation IMSIAllocation + err := d.DB.WithContext(ctx).First(&allocation).Error + if err != nil { + return nil, err + } + return &allocation, nil +} + +func (d *Database) UpdateIMSIAllocation(ctx context.Context, allocation *IMSIAllocation) error { + return d.DB.WithContext(ctx).Save(allocation).Error +} + type ListSubscribersRequest struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - Status models.SubscriberStatus `json:"status"` - OrganizationID string `json:"organization_id"` - Search string `json:"search"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Status string `json:"status"` + OrganizationID string `json:"organization_id"` + Search string `json:"search"` } From cc2590bb174b8dd0626aba78815b534fb77f07e0 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:31:21 +0300 Subject: [PATCH 14/16] fix: Reorder working-directory and env blocks in database migration step for consistency - Move working-directory after env block in Run database migrations step - Maintain same functionality while following standard YAML ordering convention --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6995149..995a056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,9 @@ jobs: run: echo "$HOME/go/bin" >> $GITHUB_PATH - name: Run database migrations - working-directory: . env: DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db?sslmode=disable + working-directory: . run: goose postgres $DATABASE_URL up migrations - name: Run linter From 393685846c9b9edfb23f044f856fbadefb76580a Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:31:29 +0300 Subject: [PATCH 15/16] fix: Convert Status field to string in ListSubscribers database request - Cast req.Status to string when passing to database.ListSubscribersRequest - Ensure type compatibility between service and database layer status fields --- apps/api-server/internal/services/subscriber_crud.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-server/internal/services/subscriber_crud.go b/apps/api-server/internal/services/subscriber_crud.go index c184cae..86a5eac 100644 --- a/apps/api-server/internal/services/subscriber_crud.go +++ b/apps/api-server/internal/services/subscriber_crud.go @@ -109,7 +109,7 @@ func (s *SubscriberService) ListSubscribers(ctx context.Context, req *ListSubscr dbReq := &database.ListSubscribersRequest{ Page: req.Page, PageSize: req.PageSize, - Status: req.Status, + Status: string(req.Status), OrganizationID: req.OrganizationID, Search: req.Search, } From 898e58201f286caec34e418cd41a52e62356ac8d Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Wed, 29 Apr 2026 03:36:45 +0300 Subject: [PATCH 16/16] refactor: Extract CRUD operations from database.go into separate crud.go file - Move all subscriber, payment method, transaction, and notification CRUD operations to crud.go - Remove models import from database.go as it's no longer needed - Keep database initialization, connection management, and IMSI allocation in database.go - Maintain ListSubscribersRequest struct definition in crud.go alongside related functions --- apps/api-server/internal/database/crud.go | 158 ++++++++++++++++++ apps/api-server/internal/database/database.go | 152 ----------------- 2 files changed, 158 insertions(+), 152 deletions(-) create mode 100644 apps/api-server/internal/database/crud.go diff --git a/apps/api-server/internal/database/crud.go b/apps/api-server/internal/database/crud.go new file mode 100644 index 0000000..e9f01ab --- /dev/null +++ b/apps/api-server/internal/database/crud.go @@ -0,0 +1,158 @@ +package database + +import ( + "context" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "gorm.io/gorm" +) + +// Subscriber CRUD +func (d *Database) CreateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { + return d.DB.WithContext(ctx).Create(subscriber).Error +} + +func (d *Database) GetSubscriber(ctx context.Context, id uint) (*models.Subscriber, error) { + var subscriber models.Subscriber + err := d.DB.WithContext(ctx).Preload("Plan").First(&subscriber, id).Error + if err != nil { + return nil, err + } + return &subscriber, nil +} + +func (d *Database) GetSubscriberByIMSI(ctx context.Context, imsi models.IMSI) (*models.Subscriber, error) { + var subscriber models.Subscriber + err := d.DB.WithContext(ctx).Preload("Plan").Where("imsi = ?", imsi).First(&subscriber).Error + if err != nil { + return nil, err + } + return &subscriber, nil +} + +func (d *Database) UpdateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { + return d.DB.WithContext(ctx).Save(subscriber).Error +} + +func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) ([]models.Subscriber, int64, error) { + var subscribers []models.Subscriber + var total int64 + + query := d.DB.WithContext(ctx).Model(&models.Subscriber{}) + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if req.OrganizationID != "" { + query = query.Where("organization_id = ?", req.OrganizationID) + } + if req.Search != "" { + query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?", + "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%") + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (req.Page - 1) * req.PageSize + err := query.Preload("Plan").Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&subscribers).Error + return subscribers, total, err +} + +func (d *Database) GetActiveSessionsByIMSI(ctx context.Context, imsi models.IMSI) ([]models.Session, error) { + var sessions []models.Session + err := d.DB.WithContext(ctx).Where("subscriber_id = ? AND status = ?", imsi, "active").Find(&sessions).Error + return sessions, err +} + +func (d *Database) UpdateSession(ctx context.Context, session *models.Session) error { + return d.DB.WithContext(ctx).Save(session).Error +} + +func (d *Database) UpdateSubscriberBalance(ctx context.Context, subscriberID uint, amount float64) error { + return d.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("id = ?", subscriberID). + UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error +} + +// Payment methods +func (d *Database) CreatePaymentMethod(ctx context.Context, pm *models.PaymentMethod) error { + return d.DB.WithContext(ctx).Create(pm).Error +} + +func (d *Database) GetPaymentMethod(ctx context.Context, id string) (*models.PaymentMethod, error) { + var pm models.PaymentMethod + err := d.DB.WithContext(ctx).Where("id = ?", id).First(&pm).Error + if err != nil { + return nil, err + } + return &pm, nil +} + +func (d *Database) ListPaymentMethods(ctx context.Context, subscriberID uint) ([]models.PaymentMethod, error) { + var methods []models.PaymentMethod + err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Find(&methods).Error + return methods, err +} + +func (d *Database) DeletePaymentMethod(ctx context.Context, id string) error { + return d.DB.WithContext(ctx).Where("id = ?", id).Delete(&models.PaymentMethod{}).Error +} + +// Transactions +func (d *Database) CreateTransaction(ctx context.Context, transaction *models.Transaction) error { + return d.DB.WithContext(ctx).Create(transaction).Error +} + +func (d *Database) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) { + var tx models.Transaction + err := d.DB.WithContext(ctx).Where("transaction_id = ?", transactionID).First(&tx).Error + if err != nil { + return nil, err + } + return &tx, nil +} + +func (d *Database) GetTransactionByGatewayID(ctx context.Context, gatewayID string) (*models.Transaction, error) { + var tx models.Transaction + err := d.DB.WithContext(ctx).Where("transaction_id = ?", gatewayID).First(&tx).Error + if err != nil { + return nil, err + } + return &tx, nil +} + +func (d *Database) GetTransactionByChargeID(ctx context.Context, chargeID string) (*models.Transaction, error) { + var tx models.Transaction + err := d.DB.WithContext(ctx).Where("transaction_id = ?", chargeID).First(&tx).Error + if err != nil { + return nil, err + } + return &tx, nil +} + +func (d *Database) UpdateTransaction(ctx context.Context, tx *models.Transaction) error { + return d.DB.WithContext(ctx).Save(tx).Error +} + +func (d *Database) ListTransactions(ctx context.Context, subscriberID uint) ([]models.Transaction, error) { + var transactions []models.Transaction + err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Order("created_at DESC").Find(&transactions).Error + return transactions, err +} + +// Notifications and alerts +func (d *Database) CreateAlert(ctx context.Context, alert *models.Alert) error { + return d.DB.WithContext(ctx).Create(alert).Error +} + +func (d *Database) CreateNotification(ctx context.Context, notification *models.Notification) error { + return d.DB.WithContext(ctx).Create(notification).Error +} + +type ListSubscribersRequest struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Status string `json:"status"` + OrganizationID string `json:"organization_id"` + Search string `json:"search"` +} diff --git a/apps/api-server/internal/database/database.go b/apps/api-server/internal/database/database.go index 098ee50..7277df7 100644 --- a/apps/api-server/internal/database/database.go +++ b/apps/api-server/internal/database/database.go @@ -12,7 +12,6 @@ import ( "gorm.io/gorm/logger" "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" ) type Database struct { @@ -104,149 +103,6 @@ func (d *Database) Ping(ctx context.Context) error { return sqlDB.PingContext(ctx) } -// Subscriber CRUD -func (d *Database) CreateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { - return d.DB.WithContext(ctx).Create(subscriber).Error -} - -func (d *Database) GetSubscriber(ctx context.Context, id uint) (*models.Subscriber, error) { - var subscriber models.Subscriber - err := d.DB.WithContext(ctx).Preload("Plan").First(&subscriber, id).Error - if err != nil { - return nil, err - } - return &subscriber, nil -} - -func (d *Database) GetSubscriberByIMSI(ctx context.Context, imsi models.IMSI) (*models.Subscriber, error) { - var subscriber models.Subscriber - err := d.DB.WithContext(ctx).Preload("Plan").Where("imsi = ?", imsi).First(&subscriber).Error - if err != nil { - return nil, err - } - return &subscriber, nil -} - -func (d *Database) UpdateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { - return d.DB.WithContext(ctx).Save(subscriber).Error -} - -func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) ([]models.Subscriber, int64, error) { - var subscribers []models.Subscriber - var total int64 - - query := d.DB.WithContext(ctx).Model(&models.Subscriber{}) - if req.Status != "" { - query = query.Where("status = ?", req.Status) - } - if req.OrganizationID != "" { - query = query.Where("organization_id = ?", req.OrganizationID) - } - if req.Search != "" { - query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?", - "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%") - } - - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - offset := (req.Page - 1) * req.PageSize - err := query.Preload("Plan").Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&subscribers).Error - return subscribers, total, err -} - -func (d *Database) GetActiveSessionsByIMSI(ctx context.Context, imsi models.IMSI) ([]models.Session, error) { - var sessions []models.Session - err := d.DB.WithContext(ctx).Where("subscriber_id = ? AND status = ?", imsi, "active").Find(&sessions).Error - return sessions, err -} - -func (d *Database) UpdateSession(ctx context.Context, session *models.Session) error { - return d.DB.WithContext(ctx).Save(session).Error -} - -// Payment methods -func (d *Database) CreatePaymentMethod(ctx context.Context, pm *models.PaymentMethod) error { - return d.DB.WithContext(ctx).Create(pm).Error -} - -func (d *Database) GetPaymentMethod(ctx context.Context, id string) (*models.PaymentMethod, error) { - var pm models.PaymentMethod - err := d.DB.WithContext(ctx).Where("id = ?", id).First(&pm).Error - if err != nil { - return nil, err - } - return &pm, nil -} - -func (d *Database) ListPaymentMethods(ctx context.Context, subscriberID uint) ([]models.PaymentMethod, error) { - var methods []models.PaymentMethod - err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Find(&methods).Error - return methods, err -} - -// Transactions -func (d *Database) CreateTransaction(ctx context.Context, transaction *models.Transaction) error { - return d.DB.WithContext(ctx).Create(transaction).Error -} - -func (d *Database) GetTransaction(ctx context.Context, transactionID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", transactionID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) GetTransactionByGatewayID(ctx context.Context, gatewayID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", gatewayID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) GetTransactionByChargeID(ctx context.Context, chargeID string) (*models.Transaction, error) { - var tx models.Transaction - err := d.DB.WithContext(ctx).Where("transaction_id = ?", chargeID).First(&tx).Error - if err != nil { - return nil, err - } - return &tx, nil -} - -func (d *Database) UpdateTransaction(ctx context.Context, tx *models.Transaction) error { - return d.DB.WithContext(ctx).Save(tx).Error -} - -func (d *Database) ListTransactions(ctx context.Context, subscriberID uint) ([]models.Transaction, error) { - var transactions []models.Transaction - err := d.DB.WithContext(ctx).Where("subscriber_id = ?", subscriberID).Order("created_at DESC").Find(&transactions).Error - return transactions, err -} - -// Additional CRUD -func (d *Database) UpdateSubscriberBalance(ctx context.Context, subscriberID uint, amount float64) error { - return d.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("id = ?", subscriberID). - UpdateColumn("balance", gorm.Expr("balance + ?", amount)).Error -} - -func (d *Database) DeletePaymentMethod(ctx context.Context, id string) error { - return d.DB.WithContext(ctx).Where("id = ?", id).Delete(&models.PaymentMethod{}).Error -} - -func (d *Database) CreateAlert(ctx context.Context, alert *models.Alert) error { - return d.DB.WithContext(ctx).Create(alert).Error -} - -func (d *Database) CreateNotification(ctx context.Context, notification *models.Notification) error { - return d.DB.WithContext(ctx).Create(notification).Error -} - -// IMSI allocation func (d *Database) GetIMSIAllocation(ctx context.Context) (*IMSIAllocation, error) { var allocation IMSIAllocation err := d.DB.WithContext(ctx).First(&allocation).Error @@ -259,11 +115,3 @@ func (d *Database) GetIMSIAllocation(ctx context.Context) (*IMSIAllocation, erro func (d *Database) UpdateIMSIAllocation(ctx context.Context, allocation *IMSIAllocation) error { return d.DB.WithContext(ctx).Save(allocation).Error } - -type ListSubscribersRequest struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - Status string `json:"status"` - OrganizationID string `json:"organization_id"` - Search string `json:"search"` -}