Files
telecom-platform/apps/carrier-connector/internal/repository/postgres.go
T
nutcas3 609bdb16fa feat: Set repository root as working directory for Goose migrations in carrier-connector
- Add filepath import for path manipulation
- Calculate repository root path relative to carrier-connector location (../../)
- Set cmd.Dir to repository root before executing Goose command
- Add comment explaining directory change to locate migrations folder
- Ensure migrations can be found from carrier-connector's nested location
2026-04-29 04:21:19 +03:00

211 lines
5.5 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// profileRow is the GORM-mapped row for the esim_profiles table.
type profileRow struct {
ICCID string `gorm:"primaryKey;size:32"`
EID string `gorm:"size:64;index"`
IMSI string `gorm:"size:20;index"`
MCC string `gorm:"size:4"`
MNC string `gorm:"size:4"`
ProfileType string `gorm:"size:32"`
State string `gorm:"size:32;index"`
TenantID string `gorm:"size:64;index"`
ActivationCode string `gorm:"size:255"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
// TableName sets the Postgres table name.
func (profileRow) TableName() string { return "esim_profiles" }
// PostgresProfileStore is a Postgres-backed ProfileRepository.
type PostgresProfileStore struct {
db *gorm.DB
}
// 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")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("open postgres: %w", err)
}
// 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
// Changes to the repo root directory to find the migrations folder
func runMigrations(dsn string) error {
// Get the repo root directory (go up from apps/carrier-connector)
repoRoot := filepath.Join("..", "..")
cmd := exec.Command("goose", "postgres", dsn, "up", "migrations")
cmd.Dir = repoRoot
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)
if err := s.db.WithContext(ctx).Create(row).Error; err != nil {
return err
}
*p = *fromRow(row)
return nil
}
// Get fetches a profile by ICCID.
func (s *PostgresProfileStore) Get(ctx context.Context, iccid string) (*Profile, error) {
var row profileRow
err := s.db.WithContext(ctx).First(&row, "iccid = ?", iccid).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return fromRow(&row), nil
}
// List returns a filtered, paginated page of profiles.
func (s *PostgresProfileStore) List(ctx context.Context, f ListFilter) ([]*Profile, int, error) {
q := s.db.WithContext(ctx).Model(&profileRow{})
if f.TenantID != "" {
q = q.Where("tenant_id = ?", f.TenantID)
}
if f.State != "" {
q = q.Where("state = ?", f.State)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
if f.Limit > 0 {
q = q.Limit(f.Limit)
}
if f.Offset > 0 {
q = q.Offset(f.Offset)
}
var rows []profileRow
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, 0, err
}
result := make([]*Profile, len(rows))
for i := range rows {
result[i] = fromRow(&rows[i])
}
return result, int(total), nil
}
// UpdateState updates the state column and returns the refreshed profile.
func (s *PostgresProfileStore) UpdateState(ctx context.Context, iccid, state string) (*Profile, error) {
res := s.db.WithContext(ctx).Model(&profileRow{}).
Where("iccid = ?", iccid).
Update("state", state)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, ErrNotFound
}
return s.Get(ctx, iccid)
}
// Delete removes a profile row.
func (s *PostgresProfileStore) Delete(ctx context.Context, iccid string) error {
res := s.db.WithContext(ctx).Delete(&profileRow{}, "iccid = ?", iccid)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// Close releases the underlying database connection.
func (s *PostgresProfileStore) Close() error {
sqlDB, err := s.db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
// Ping checks if the database connection is alive.
func (s *PostgresProfileStore) Ping() error {
sqlDB, err := s.db.DB()
if err != nil {
return err
}
return sqlDB.Ping()
}
// toRow maps domain -> row. Passthrough of CreatedAt/UpdatedAt lets GORM hooks
// populate them on first save.
func toRow(p *Profile) *profileRow {
return &profileRow{
ICCID: p.ICCID,
EID: p.EID,
IMSI: p.IMSI,
MCC: p.MCC,
MNC: p.MNC,
ProfileType: p.ProfileType,
State: p.State,
TenantID: p.TenantID,
ActivationCode: p.ActivationCode,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
// fromRow maps row -> domain.
func fromRow(r *profileRow) *Profile {
return &Profile{
ICCID: r.ICCID,
EID: r.EID,
IMSI: r.IMSI,
MCC: r.MCC,
MNC: r.MNC,
ProfileType: r.ProfileType,
State: r.State,
TenantID: r.TenantID,
ActivationCode: r.ActivationCode,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}