Files
Claude (gsc-ops-api init) 3847eb2036 Initial import — snapshot from admin host /srv/gosec/gsc-ops-api
This repo had no version control prior to this commit. The import is a
straight snapshot of the working tree at 2026-05-03; the deployed
binary on fihelvop01 was being rebuilt from this source via `make
build` + scp into place, with no upstream review path.

The snapshot already includes one in-flight fix made on 2026-05-03 to
internal/service/persona.go:GetSelfModel — the handler queried
`source` and `strength` columns plus an `is_active = true` filter on
persona.persona_commitments, none of which exist on that table (its
shape is session-bound commitments with `status`, `commitment_meta`,
etc.). The query returned a 500 every time SynapseHub bootstrapped a
persona's self-model, dropping the IdentityConstraints / Commitments /
ConscienceStandards layer from the assembled prompt. The patched
query reads existing columns only (commitment_text, commitment_type),
filters on `status='active'`, and synthesises Source="learned" /
Strength=1.0 to keep the SelfModel response shape stable for callers.

Verified live: `GET /api/v1/personas/70f7cfd9-.../self-model` now
returns 200 with `{identityConstraints:[],commitments:[],
conscienceStandards:[]}` instead of 500.

Future changes go through PRs against this repo — no more bin-only
deploys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:06:02 +02:00

1092 lines
35 KiB
Go

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
}