package service import ( "context" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/internal/client" "github.com/gosec/gsc-ops-api/pkg/types" ) // PBXService handles PBX trunk, DID, extension, and route operations type PBXService struct { pool *pgxpool.Pool // gsc_admin database asterisk *client.AsteriskClient kamailio *client.KamailioClient logger zerolog.Logger } // NewPBXService creates a new PBX service func NewPBXService(pool *pgxpool.Pool, asterisk *client.AsteriskClient, kamailio *client.KamailioClient, logger zerolog.Logger) *PBXService { return &PBXService{ pool: pool, asterisk: asterisk, kamailio: kamailio, logger: logger.With().Str("service", "pbx").Logger(), } } // ============================================================================ // Trunks // ============================================================================ // ListTrunks lists SIP trunks with optional filters func (s *PBXService) ListTrunks(ctx context.Context, params types.ListParams) ([]types.PBXTrunk, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM pbx_trunks WHERE 1=1` listQuery := `SELECT t.id, t.tenant_id, t.provider_id, t.name, COALESCE(t.description,''), t.trunk_type, t.status, t.host, t.port, t.transport, COALESCE(t.username,''), COALESCE(t.auth_realm,''), COALESCE(t.from_domain,''), COALESCE(t.from_user,''), t.register, t.codecs, t.dtmf_mode, t.nat_mode, t.max_channels, t.current_channels, COALESCE(t.outbound_caller_id_name,''), COALESCE(t.outbound_caller_id_number,''), t.priority, t.created_at, t.updated_at, (SELECT COUNT(*) FROM pbx_trunk_dids d WHERE d.trunk_id = t.id) AS did_count FROM pbx_trunks t WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Status != "" { countQuery += fmt.Sprintf(" AND status = $%d", argIdx) listQuery += fmt.Sprintf(" AND t.status = $%d", argIdx) args = append(args, params.Status) argIdx++ } if params.Search != "" { countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR host ILIKE $%d OR username ILIKE $%d)", argIdx, argIdx, argIdx) listQuery += fmt.Sprintf(" AND (t.name ILIKE $%d OR t.host ILIKE $%d OR t.username ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() trunks := make([]types.PBXTrunk, 0) for rows.Next() { var t types.PBXTrunk if err := rows.Scan(&t.ID, &t.TenantID, &t.ProviderID, &t.Name, &t.Description, &t.TrunkType, &t.Status, &t.Host, &t.Port, &t.Transport, &t.Username, &t.AuthRealm, &t.FromDomain, &t.FromUser, &t.Register, &t.Codecs, &t.DtmfMode, &t.NatMode, &t.MaxChannels, &t.CurrentChannels, &t.OutboundCallerIDName, &t.OutboundCallerIDNum, &t.Priority, &t.CreatedAt, &t.UpdatedAt, &t.DIDCount); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } trunks = append(trunks, t) } return trunks, total, nil } // GetTrunk gets a trunk by ID func (s *PBXService) GetTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { var t types.PBXTrunk err := s.pool.QueryRow(ctx, `SELECT t.id, t.tenant_id, t.provider_id, t.name, COALESCE(t.description,''), t.trunk_type, t.status, t.host, t.port, t.transport, COALESCE(t.username,''), COALESCE(t.auth_realm,''), COALESCE(t.from_domain,''), COALESCE(t.from_user,''), t.register, t.codecs, t.dtmf_mode, t.nat_mode, t.max_channels, t.current_channels, COALESCE(t.outbound_caller_id_name,''), COALESCE(t.outbound_caller_id_number,''), t.priority, t.created_at, t.updated_at, (SELECT COUNT(*) FROM pbx_trunk_dids d WHERE d.trunk_id = t.id) AS did_count FROM pbx_trunks t WHERE t.id = $1`, id). Scan(&t.ID, &t.TenantID, &t.ProviderID, &t.Name, &t.Description, &t.TrunkType, &t.Status, &t.Host, &t.Port, &t.Transport, &t.Username, &t.AuthRealm, &t.FromDomain, &t.FromUser, &t.Register, &t.Codecs, &t.DtmfMode, &t.NatMode, &t.MaxChannels, &t.CurrentChannels, &t.OutboundCallerIDName, &t.OutboundCallerIDNum, &t.Priority, &t.CreatedAt, &t.UpdatedAt, &t.DIDCount) if err != nil { return nil, err } return &t, nil } // CreateTrunk creates a new SIP trunk func (s *PBXService) CreateTrunk(ctx context.Context, req *types.PBXTrunkCreate) (*types.PBXTrunk, error) { id := uuid.New() now := time.Now().UTC() port := req.Port if port == 0 { port = 5060 } transport := req.Transport if transport == "" { transport = "udp" } codecs := req.Codecs if len(codecs) == 0 { codecs = []string{"ulaw", "alaw"} } dtmfMode := req.DtmfMode if dtmfMode == "" { dtmfMode = "rfc4733" } natMode := req.NatMode if natMode == "" { natMode = "yes" } _, err := s.pool.Exec(ctx, `INSERT INTO pbx_trunks (id, tenant_id, provider_id, name, description, trunk_type, status, host, port, transport, username, auth_realm, from_domain, from_user, register, codecs, dtmf_mode, nat_mode, max_channels, outbound_caller_id_name, outbound_caller_id_number, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, 'pjsip', 'inactive', $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $20)`, id, req.TenantID, req.ProviderID, req.Name, nilIfEmpty(req.Description), req.Host, port, transport, nilIfEmpty(req.Username), nilIfEmpty(req.AuthRealm), nilIfEmpty(req.FromDomain), nilIfEmpty(req.FromUser), req.Register, codecs, dtmfMode, natMode, req.MaxChannels, nilIfEmpty(req.OutboundCallerIDName), nilIfEmpty(req.OutboundCallerIDNum), now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } // If password provided, store in sip_passwords via asterisk DB // (handled by caller or separate sync endpoint) return s.GetTrunk(ctx, id) } // UpdateTrunk updates a trunk func (s *PBXService) UpdateTrunk(ctx context.Context, id uuid.UUID, req *types.PBXTrunkUpdate) (*types.PBXTrunk, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.Description != nil { setClauses = append(setClauses, fmt.Sprintf("description = $%d", argIdx)) args = append(args, *req.Description) argIdx++ } if req.Host != nil { setClauses = append(setClauses, fmt.Sprintf("host = $%d", argIdx)) args = append(args, *req.Host) argIdx++ } if req.Port != nil { setClauses = append(setClauses, fmt.Sprintf("port = $%d", argIdx)) args = append(args, *req.Port) argIdx++ } if req.Transport != nil { setClauses = append(setClauses, fmt.Sprintf("transport = $%d", argIdx)) args = append(args, *req.Transport) argIdx++ } if req.Username != nil { setClauses = append(setClauses, fmt.Sprintf("username = $%d", argIdx)) args = append(args, *req.Username) argIdx++ } if req.AuthRealm != nil { setClauses = append(setClauses, fmt.Sprintf("auth_realm = $%d", argIdx)) args = append(args, *req.AuthRealm) argIdx++ } if req.FromDomain != nil { setClauses = append(setClauses, fmt.Sprintf("from_domain = $%d", argIdx)) args = append(args, *req.FromDomain) argIdx++ } if req.FromUser != nil { setClauses = append(setClauses, fmt.Sprintf("from_user = $%d", argIdx)) args = append(args, *req.FromUser) argIdx++ } if req.Register != nil { setClauses = append(setClauses, fmt.Sprintf("register = $%d", argIdx)) args = append(args, *req.Register) argIdx++ } if req.Codecs != nil { setClauses = append(setClauses, fmt.Sprintf("codecs = $%d", argIdx)) args = append(args, req.Codecs) argIdx++ } if req.DtmfMode != nil { setClauses = append(setClauses, fmt.Sprintf("dtmf_mode = $%d", argIdx)) args = append(args, *req.DtmfMode) argIdx++ } if req.NatMode != nil { setClauses = append(setClauses, fmt.Sprintf("nat_mode = $%d", argIdx)) args = append(args, *req.NatMode) argIdx++ } if req.MaxChannels != nil { setClauses = append(setClauses, fmt.Sprintf("max_channels = $%d", argIdx)) args = append(args, *req.MaxChannels) argIdx++ } if req.OutboundCallerIDName != nil { setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_name = $%d", argIdx)) args = append(args, *req.OutboundCallerIDName) argIdx++ } if req.OutboundCallerIDNum != nil { setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_number = $%d", argIdx)) args = append(args, *req.OutboundCallerIDNum) argIdx++ } if req.Status != nil { setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx)) args = append(args, *req.Status) argIdx++ } if len(setClauses) == 0 { return s.GetTrunk(ctx, id) } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE pbx_trunks SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } return s.GetTrunk(ctx, id) } // DeleteTrunk soft-deletes a trunk by setting status to inactive func (s *PBXService) DeleteTrunk(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, `UPDATE pbx_trunks SET status = 'inactive', updated_at = $1 WHERE id = $2`, time.Now().UTC(), id) if err != nil { return err } // Reload Asterisk and Kamailio to remove the trunk if s.asterisk != nil { go s.asterisk.ReloadPJSIP() } if s.kamailio != nil { go s.kamailio.ReloadDispatcher() } return nil } // ActivateTrunk sets trunk status to active and reloads Asterisk + Kamailio func (s *PBXService) ActivateTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { _, err := s.pool.Exec(ctx, `UPDATE pbx_trunks SET status = 'active', updated_at = $1 WHERE id = $2`, time.Now().UTC(), id) if err != nil { return nil, fmt.Errorf("activate failed: %w", err) } // Reload Asterisk PJSIP to pick up new trunk if s.asterisk != nil { go s.asterisk.ReloadPJSIP() } // Reload Kamailio dispatcher in case routing changed if s.kamailio != nil { go s.kamailio.ReloadDispatcher() } return s.GetTrunk(ctx, id) } // DeactivateTrunk sets trunk status to inactive and reloads Asterisk + Kamailio func (s *PBXService) DeactivateTrunk(ctx context.Context, id uuid.UUID) (*types.PBXTrunk, error) { _, err := s.pool.Exec(ctx, `UPDATE pbx_trunks SET status = 'inactive', updated_at = $1 WHERE id = $2`, time.Now().UTC(), id) if err != nil { return nil, fmt.Errorf("deactivate failed: %w", err) } // Reload Asterisk if s.asterisk != nil { go s.asterisk.ReloadPJSIP() } // Reload Kamailio dispatcher if s.kamailio != nil { go s.kamailio.ReloadDispatcher() } return s.GetTrunk(ctx, id) } // ============================================================================ // Trunk DIDs // ============================================================================ // ListTrunkDIDs lists DIDs for a trunk func (s *PBXService) ListTrunkDIDs(ctx context.Context, trunkID uuid.UUID) ([]types.PBXTrunkDID, error) { rows, err := s.pool.Query(ctx, `SELECT id, trunk_id, tenant_id, did_number, description, destination_type, destination_id, is_active, created_at FROM pbx_trunk_dids WHERE trunk_id = $1 ORDER BY did_number`, trunkID) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } defer rows.Close() dids := make([]types.PBXTrunkDID, 0) for rows.Next() { var d types.PBXTrunkDID if err := rows.Scan(&d.ID, &d.TrunkID, &d.TenantID, &d.DIDNumber, &d.Description, &d.DestinationType, &d.DestinationID, &d.IsActive, &d.CreatedAt); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } dids = append(dids, d) } return dids, nil } // CreateTrunkDID adds a DID to a trunk func (s *PBXService) CreateTrunkDID(ctx context.Context, trunkID uuid.UUID, req *types.PBXTrunkDIDCreate) (*types.PBXTrunkDID, error) { id := uuid.New() now := time.Now().UTC() _, err := s.pool.Exec(ctx, `INSERT INTO pbx_trunk_dids (id, trunk_id, tenant_id, did_number, description, destination_type, destination_id, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $8)`, id, trunkID, req.TenantID, req.DIDNumber, nilIfEmpty(req.Description), nilIfEmpty(req.DestinationType), req.DestinationID, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } var d types.PBXTrunkDID err = s.pool.QueryRow(ctx, `SELECT id, trunk_id, tenant_id, did_number, description, destination_type, destination_id, is_active, created_at FROM pbx_trunk_dids WHERE id = $1`, id). Scan(&d.ID, &d.TrunkID, &d.TenantID, &d.DIDNumber, &d.Description, &d.DestinationType, &d.DestinationID, &d.IsActive, &d.CreatedAt) if err != nil { return nil, err } return &d, nil } // DeleteTrunkDID removes a DID from a trunk func (s *PBXService) DeleteTrunkDID(ctx context.Context, trunkID, didID uuid.UUID) error { _, err := s.pool.Exec(ctx, `DELETE FROM pbx_trunk_dids WHERE id = $1 AND trunk_id = $2`, didID, trunkID) return err } // ============================================================================ // Extensions // ============================================================================ // ListExtensions lists extensions with optional filters func (s *PBXService) ListExtensions(ctx context.Context, params types.ListParams) ([]types.PBXExtension, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM pbx_extensions WHERE 1=1` listQuery := `SELECT id, tenant_id, user_id, extension, name, extension_type, caller_id_name, caller_id_number, outbound_caller_id_number, sip_username, transport, codecs, voicemail_enabled, is_active, created_at, updated_at FROM pbx_extensions WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Status != "" { if params.Status == "active" { countQuery += " AND is_active = true" listQuery += " AND is_active = true" } else if params.Status == "inactive" { countQuery += " AND is_active = false" listQuery += " AND is_active = false" } } if params.Search != "" { countQuery += fmt.Sprintf(" AND (extension ILIKE $%d OR name ILIKE $%d OR sip_username ILIKE $%d)", argIdx, argIdx, argIdx) listQuery += fmt.Sprintf(" AND (extension ILIKE $%d OR name ILIKE $%d OR sip_username ILIKE $%d)", argIdx, argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY extension ASC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() exts := make([]types.PBXExtension, 0) for rows.Next() { var e types.PBXExtension if err := rows.Scan(&e.ID, &e.TenantID, &e.UserID, &e.Extension, &e.Name, &e.ExtensionType, &e.CallerIDName, &e.CallerIDNumber, &e.OutboundCallerIDNumber, &e.SIPUsername, &e.Transport, &e.Codecs, &e.VoicemailEnabled, &e.IsActive, &e.CreatedAt, &e.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } exts = append(exts, e) } return exts, total, nil } // GetExtension gets an extension by ID func (s *PBXService) GetExtension(ctx context.Context, id uuid.UUID) (*types.PBXExtension, error) { var e types.PBXExtension err := s.pool.QueryRow(ctx, `SELECT id, tenant_id, user_id, extension, name, extension_type, caller_id_name, caller_id_number, outbound_caller_id_number, sip_username, transport, codecs, voicemail_enabled, is_active, created_at, updated_at FROM pbx_extensions WHERE id = $1`, id). Scan(&e.ID, &e.TenantID, &e.UserID, &e.Extension, &e.Name, &e.ExtensionType, &e.CallerIDName, &e.CallerIDNumber, &e.OutboundCallerIDNumber, &e.SIPUsername, &e.Transport, &e.Codecs, &e.VoicemailEnabled, &e.IsActive, &e.CreatedAt, &e.UpdatedAt) if err != nil { return nil, err } return &e, nil } // CreateExtension creates a new extension func (s *PBXService) CreateExtension(ctx context.Context, req *types.PBXExtensionCreate) (*types.PBXExtension, error) { id := uuid.New() now := time.Now().UTC() extType := req.ExtensionType if extType == "" { extType = "user" } transport := req.Transport if transport == "" { transport = "udp" } codecs := req.Codecs if len(codecs) == 0 { codecs = []string{"opus", "ulaw", "alaw"} } sipUsername := req.SIPUsername if sipUsername == "" { sipUsername = req.Extension } _, err := s.pool.Exec(ctx, `INSERT INTO pbx_extensions (id, tenant_id, user_id, extension, name, extension_type, caller_id_name, caller_id_number, outbound_caller_id_number, sip_username, transport, codecs, voicemail_enabled, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, true, $14, $14)`, id, req.TenantID, req.UserID, req.Extension, req.Name, extType, nilIfEmpty(req.CallerIDName), nilIfEmpty(req.CallerIDNumber), nilIfEmpty(req.OutboundCallerIDNumber), sipUsername, transport, codecs, req.VoicemailEnabled, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } return s.GetExtension(ctx, id) } // UpdateExtension updates an extension func (s *PBXService) UpdateExtension(ctx context.Context, id uuid.UUID, req *types.PBXExtensionUpdate) (*types.PBXExtension, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.CallerIDName != nil { setClauses = append(setClauses, fmt.Sprintf("caller_id_name = $%d", argIdx)) args = append(args, *req.CallerIDName) argIdx++ } if req.CallerIDNumber != nil { setClauses = append(setClauses, fmt.Sprintf("caller_id_number = $%d", argIdx)) args = append(args, *req.CallerIDNumber) argIdx++ } if req.OutboundCallerIDNumber != nil { setClauses = append(setClauses, fmt.Sprintf("outbound_caller_id_number = $%d", argIdx)) args = append(args, *req.OutboundCallerIDNumber) argIdx++ } if req.Codecs != nil { setClauses = append(setClauses, fmt.Sprintf("codecs = $%d", argIdx)) args = append(args, req.Codecs) argIdx++ } if req.VoicemailEnabled != nil { setClauses = append(setClauses, fmt.Sprintf("voicemail_enabled = $%d", argIdx)) args = append(args, *req.VoicemailEnabled) argIdx++ } if req.IsActive != nil { setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) args = append(args, *req.IsActive) argIdx++ } if len(setClauses) == 0 { return s.GetExtension(ctx, id) } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE pbx_extensions SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } return s.GetExtension(ctx, id) } // DeleteExtension soft-deletes an extension func (s *PBXService) DeleteExtension(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, `UPDATE pbx_extensions SET is_active = false, updated_at = $1 WHERE id = $2`, time.Now().UTC(), id) return err } // ============================================================================ // Inbound Routes // ============================================================================ // ListInboundRoutes lists inbound routes func (s *PBXService) ListInboundRoutes(ctx context.Context, params types.ListParams) ([]types.PBXInboundRoute, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM pbx_inbound_routes WHERE 1=1` listQuery := `SELECT id, tenant_id, name, did_number, destination_type, destination_id, destination_data, priority, is_active, trunk_id, created_at, updated_at FROM pbx_inbound_routes WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Search != "" { countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR did_number ILIKE $%d)", argIdx, argIdx) listQuery += fmt.Sprintf(" AND (name ILIKE $%d OR did_number ILIKE $%d)", argIdx, argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY priority ASC, created_at DESC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() routes := make([]types.PBXInboundRoute, 0) for rows.Next() { var r types.PBXInboundRoute if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.DIDNumber, &r.DestinationType, &r.DestinationID, &r.DestinationData, &r.Priority, &r.IsActive, &r.TrunkID, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } routes = append(routes, r) } return routes, total, nil } // GetInboundRoute gets an inbound route by ID func (s *PBXService) GetInboundRoute(ctx context.Context, id uuid.UUID) (*types.PBXInboundRoute, error) { var r types.PBXInboundRoute err := s.pool.QueryRow(ctx, `SELECT id, tenant_id, name, did_number, destination_type, destination_id, destination_data, priority, is_active, trunk_id, created_at, updated_at FROM pbx_inbound_routes WHERE id = $1`, id). Scan(&r.ID, &r.TenantID, &r.Name, &r.DIDNumber, &r.DestinationType, &r.DestinationID, &r.DestinationData, &r.Priority, &r.IsActive, &r.TrunkID, &r.CreatedAt, &r.UpdatedAt) if err != nil { return nil, err } return &r, nil } // CreateInboundRoute creates a new inbound route func (s *PBXService) CreateInboundRoute(ctx context.Context, req *types.PBXInboundRouteCreate) (*types.PBXInboundRoute, error) { id := uuid.New() now := time.Now().UTC() priority := req.Priority if priority == 0 { priority = 100 } _, err := s.pool.Exec(ctx, `INSERT INTO pbx_inbound_routes (id, tenant_id, name, did_number, destination_type, destination_id, destination_data, priority, is_active, trunk_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $10)`, id, req.TenantID, req.Name, nilIfEmpty(req.DIDNumber), req.DestinationType, req.DestinationID, nilIfEmpty(req.DestinationData), priority, req.TrunkID, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } return s.GetInboundRoute(ctx, id) } // UpdateInboundRoute updates an inbound route func (s *PBXService) UpdateInboundRoute(ctx context.Context, id uuid.UUID, req *types.PBXInboundRouteUpdate) (*types.PBXInboundRoute, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.DIDNumber != nil { setClauses = append(setClauses, fmt.Sprintf("did_number = $%d", argIdx)) args = append(args, *req.DIDNumber) argIdx++ } if req.DestinationType != nil { setClauses = append(setClauses, fmt.Sprintf("destination_type = $%d", argIdx)) args = append(args, *req.DestinationType) argIdx++ } if req.DestinationID != nil { setClauses = append(setClauses, fmt.Sprintf("destination_id = $%d", argIdx)) args = append(args, *req.DestinationID) argIdx++ } if req.DestinationData != nil { setClauses = append(setClauses, fmt.Sprintf("destination_data = $%d", argIdx)) args = append(args, *req.DestinationData) argIdx++ } if req.Priority != nil { setClauses = append(setClauses, fmt.Sprintf("priority = $%d", argIdx)) args = append(args, *req.Priority) argIdx++ } if req.IsActive != nil { setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) args = append(args, *req.IsActive) argIdx++ } if req.TrunkID != nil { setClauses = append(setClauses, fmt.Sprintf("trunk_id = $%d", argIdx)) args = append(args, *req.TrunkID) argIdx++ } if len(setClauses) == 0 { return s.GetInboundRoute(ctx, id) } setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE pbx_inbound_routes SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } return s.GetInboundRoute(ctx, id) } // DeleteInboundRoute deletes an inbound route func (s *PBXService) DeleteInboundRoute(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, `DELETE FROM pbx_inbound_routes WHERE id = $1`, id) return err } // ============================================================================ // Outbound Routes // ============================================================================ // ListOutboundRoutes lists outbound routes func (s *PBXService) ListOutboundRoutes(ctx context.Context, params types.ListParams) ([]types.PBXOutboundRoute, int64, error) { params = types.DefaultListParams(params) countQuery := `SELECT COUNT(*) FROM pbx_outbound_routes WHERE 1=1` listQuery := `SELECT id, tenant_id, name, priority, dial_patterns, prepend, prefix, strip_digits, override_caller_id, caller_id_name, caller_id_number, is_emergency, is_active, created_at, updated_at FROM pbx_outbound_routes WHERE 1=1` args := []interface{}{} argIdx := 1 if params.Search != "" { countQuery += fmt.Sprintf(" AND name ILIKE $%d", argIdx) listQuery += fmt.Sprintf(" AND name ILIKE $%d", argIdx) args = append(args, "%"+params.Search+"%") argIdx++ } var total int64 if err := s.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("count query failed: %w", err) } listQuery += fmt.Sprintf(" ORDER BY priority ASC LIMIT $%d OFFSET $%d", argIdx, argIdx+1) args = append(args, params.Limit, params.Offset) rows, err := s.pool.Query(ctx, listQuery, args...) if err != nil { return nil, 0, fmt.Errorf("list query failed: %w", err) } defer rows.Close() routes := make([]types.PBXOutboundRoute, 0) for rows.Next() { var r types.PBXOutboundRoute if err := rows.Scan(&r.ID, &r.TenantID, &r.Name, &r.Priority, &r.DialPatterns, &r.Prepend, &r.Prefix, &r.StripDigits, &r.OverrideCallerID, &r.CallerIDName, &r.CallerIDNumber, &r.IsEmergency, &r.IsActive, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, 0, fmt.Errorf("scan failed: %w", err) } // Load trunks for this route trunkRows, err := s.pool.Query(ctx, `SELECT ort.trunk_id, t.name, ort.priority FROM pbx_outbound_route_trunks ort JOIN pbx_trunks t ON t.id = ort.trunk_id WHERE ort.outbound_route_id = $1 ORDER BY ort.priority`, r.ID) if err == nil { for trunkRows.Next() { var rt types.PBXOutboundRouteTrunk trunkRows.Scan(&rt.TrunkID, &rt.TrunkName, &rt.Priority) r.Trunks = append(r.Trunks, rt) } trunkRows.Close() } routes = append(routes, r) } return routes, total, nil } // GetOutboundRoute gets an outbound route by ID func (s *PBXService) GetOutboundRoute(ctx context.Context, id uuid.UUID) (*types.PBXOutboundRoute, error) { var r types.PBXOutboundRoute err := s.pool.QueryRow(ctx, `SELECT id, tenant_id, name, priority, dial_patterns, prepend, prefix, strip_digits, override_caller_id, caller_id_name, caller_id_number, is_emergency, is_active, created_at, updated_at FROM pbx_outbound_routes WHERE id = $1`, id). Scan(&r.ID, &r.TenantID, &r.Name, &r.Priority, &r.DialPatterns, &r.Prepend, &r.Prefix, &r.StripDigits, &r.OverrideCallerID, &r.CallerIDName, &r.CallerIDNumber, &r.IsEmergency, &r.IsActive, &r.CreatedAt, &r.UpdatedAt) if err != nil { return nil, err } // Load trunks trunkRows, err := s.pool.Query(ctx, `SELECT ort.trunk_id, t.name, ort.priority FROM pbx_outbound_route_trunks ort JOIN pbx_trunks t ON t.id = ort.trunk_id WHERE ort.outbound_route_id = $1 ORDER BY ort.priority`, id) if err == nil { for trunkRows.Next() { var rt types.PBXOutboundRouteTrunk trunkRows.Scan(&rt.TrunkID, &rt.TrunkName, &rt.Priority) r.Trunks = append(r.Trunks, rt) } trunkRows.Close() } return &r, nil } // CreateOutboundRoute creates a new outbound route func (s *PBXService) CreateOutboundRoute(ctx context.Context, req *types.PBXOutboundRouteCreate) (*types.PBXOutboundRoute, error) { id := uuid.New() now := time.Now().UTC() priority := req.Priority if priority == 0 { priority = 100 } _, err := s.pool.Exec(ctx, `INSERT INTO pbx_outbound_routes (id, tenant_id, name, priority, dial_patterns, prepend, prefix, strip_digits, override_caller_id, caller_id_name, caller_id_number, is_emergency, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, true, $13, $13)`, id, req.TenantID, req.Name, priority, req.DialPatterns, nilIfEmpty(req.Prepend), nilIfEmpty(req.Prefix), req.StripDigits, req.OverrideCallerID, nilIfEmpty(req.CallerIDName), nilIfEmpty(req.CallerIDNumber), req.IsEmergency, now) if err != nil { return nil, fmt.Errorf("insert failed: %w", err) } // Insert trunk assignments for _, t := range req.Trunks { s.pool.Exec(ctx, `INSERT INTO pbx_outbound_route_trunks (id, outbound_route_id, trunk_id, priority, created_at) VALUES ($1, $2, $3, $4, $5)`, uuid.New(), id, t.TrunkID, t.Priority, now) } return s.GetOutboundRoute(ctx, id) } // UpdateOutboundRoute updates an outbound route func (s *PBXService) UpdateOutboundRoute(ctx context.Context, id uuid.UUID, req *types.PBXOutboundRouteUpdate) (*types.PBXOutboundRoute, error) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.Priority != nil { setClauses = append(setClauses, fmt.Sprintf("priority = $%d", argIdx)) args = append(args, *req.Priority) argIdx++ } if req.DialPatterns != nil { setClauses = append(setClauses, fmt.Sprintf("dial_patterns = $%d", argIdx)) args = append(args, req.DialPatterns) argIdx++ } if req.Prepend != nil { setClauses = append(setClauses, fmt.Sprintf("prepend = $%d", argIdx)) args = append(args, *req.Prepend) argIdx++ } if req.Prefix != nil { setClauses = append(setClauses, fmt.Sprintf("prefix = $%d", argIdx)) args = append(args, *req.Prefix) argIdx++ } if req.StripDigits != nil { setClauses = append(setClauses, fmt.Sprintf("strip_digits = $%d", argIdx)) args = append(args, *req.StripDigits) argIdx++ } if req.OverrideCallerID != nil { setClauses = append(setClauses, fmt.Sprintf("override_caller_id = $%d", argIdx)) args = append(args, *req.OverrideCallerID) argIdx++ } if req.CallerIDName != nil { setClauses = append(setClauses, fmt.Sprintf("caller_id_name = $%d", argIdx)) args = append(args, *req.CallerIDName) argIdx++ } if req.CallerIDNumber != nil { setClauses = append(setClauses, fmt.Sprintf("caller_id_number = $%d", argIdx)) args = append(args, *req.CallerIDNumber) argIdx++ } if req.IsEmergency != nil { setClauses = append(setClauses, fmt.Sprintf("is_emergency = $%d", argIdx)) args = append(args, *req.IsEmergency) argIdx++ } if req.IsActive != nil { setClauses = append(setClauses, fmt.Sprintf("is_active = $%d", argIdx)) args = append(args, *req.IsActive) argIdx++ } if len(setClauses) > 0 { setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argIdx)) args = append(args, time.Now().UTC()) argIdx++ args = append(args, id) query := fmt.Sprintf("UPDATE pbx_outbound_routes SET %s WHERE id = $%d", join(setClauses, ", "), argIdx) _, err := s.pool.Exec(ctx, query, args...) if err != nil { return nil, fmt.Errorf("update failed: %w", err) } } // Update trunk assignments if provided if req.Trunks != nil { s.pool.Exec(ctx, `DELETE FROM pbx_outbound_route_trunks WHERE outbound_route_id = $1`, id) now := time.Now().UTC() for _, t := range req.Trunks { s.pool.Exec(ctx, `INSERT INTO pbx_outbound_route_trunks (id, outbound_route_id, trunk_id, priority, created_at) VALUES ($1, $2, $3, $4, $5)`, uuid.New(), id, t.TrunkID, t.Priority, now) } } return s.GetOutboundRoute(ctx, id) } // DeleteOutboundRoute deletes an outbound route func (s *PBXService) DeleteOutboundRoute(ctx context.Context, id uuid.UUID) error { _, err := s.pool.Exec(ctx, `DELETE FROM pbx_outbound_routes WHERE id = $1`, id) return err } // ============================================================================ // System Operations // ============================================================================ // GetStatus returns PBX system status func (s *PBXService) GetStatus(ctx context.Context) (*types.PBXStatus, error) { status := &types.PBXStatus{} // Count active trunks s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pbx_trunks WHERE status = 'active'`).Scan(&status.ActiveTrunks) // Count active extensions s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM pbx_extensions WHERE is_active = true`).Scan(&status.ActiveExtensions) // Asterisk server status if s.asterisk != nil { for _, srv := range s.asterisk.Servers() { ss := types.PBXServerStatus{Host: srv.Host, Status: "unknown"} results := s.asterisk.GetUptime() for _, r := range results { if r.Host == srv.Host { if r.Success { ss.Status = "online" ss.Uptime = r.Output } else { ss.Status = "offline" } } } status.AsteriskServers = append(status.AsteriskServers, ss) } } // Kamailio server status if s.kamailio != nil { for _, srv := range s.kamailio.Servers() { status.KamailioServers = append(status.KamailioServers, types.PBXServerStatus{ Host: srv, Status: "configured", }) } } return status, nil } // Reload triggers a reload on all Asterisk and Kamailio servers func (s *PBXService) Reload(ctx context.Context) (*types.PBXReloadResult, error) { result := &types.PBXReloadResult{} if s.asterisk != nil { astResults := s.asterisk.ReloadAll() for _, r := range astResults { result.AsteriskResults = append(result.AsteriskResults, types.PBXReloadServer{ Host: r.Host, Success: r.Success, Message: r.Output, }) } } if s.kamailio != nil { kamResults := s.kamailio.ReloadAll() for _, r := range kamResults { result.KamailioResults = append(result.KamailioResults, types.PBXReloadServer{ Host: r.Host, Success: r.Success, Message: r.Output, }) } } return result, nil }