From cb8b6c4d96e560c76e982889b07199a13a9337ee Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Fri, 24 Apr 2026 08:59:09 +0300 Subject: [PATCH] feat: Add PostgresProfileStore repository with GORM-based persistence for eSIM profile lifecycle management - Add profileRow struct with ICCID/EID/IMSI/MCC/MNC/ProfileType/State/TenantID/ActivationCode/CreatedAt/UpdatedAt fields mapped to esim_profiles table with indexes on EID/IMSI/State/TenantID - Add NewPostgresProfileStore with DSN validation, GORM connection setup with Warn logging, and AutoMigrate for esim_profiles schema - Add Create/Get/List/UpdateState/Delete methods implementing ProfileRepository interface with --- .../internal/repository/postgres.go | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 apps/carrier-connector/internal/repository/postgres.go diff --git a/apps/carrier-connector/internal/repository/postgres.go b/apps/carrier-connector/internal/repository/postgres.go new file mode 100644 index 0000000..1185abd --- /dev/null +++ b/apps/carrier-connector/internal/repository/postgres.go @@ -0,0 +1,180 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "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, runs the +// profile schema migration, and returns a repository ready to use. +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) + } + if err := db.AutoMigrate(&profileRow{}); err != nil { + return nil, fmt.Errorf("migrate esim_profiles: %w", err) + } + return &PostgresProfileStore{db: db}, 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() +} + +// 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, + } +}