Merge pull request #16 from nutcas3/improv

Improv
This commit is contained in:
Nutcase
2026-05-01 19:16:33 +03:00
committed by GitHub
65 changed files with 3376 additions and 1961 deletions
+146
View File
@@ -0,0 +1,146 @@
# Rustfmt configuration for consistent code formatting across the workspace
# Maximum line width
max_width = 100
# Use 4 spaces for indentation
tab_spaces = 4
# Use spaces instead of tabs
hard_tabs = false
# Edition to use
edition = "2021"
# How to use heuristics for splitting lines
use_small_heuristics = "Max"
# Reorder import statements
reorder_imports = true
reorder_modules = true
# Group imports
group_imports = "StdExternalCrate"
# Format comments
comment_width = 80
wrap_comments = true
normalize_comments = true
# Format strings
format_strings = true
# Use field init shorthand
use_field_init_shorthand = true
# Use try shorthand
use_try_shorthand = true
# Force explicit type for struct fields where possible
force_explicit_abi = true
# Format code in doc comments
format_code_in_doc_comments = true
# Normalize doc attributes
normalize_doc_attributes = true
# Wrap match arms
match_arm_blocks = false
match_block_trailing_comma = true
# Trailing comma for lists
trailing_comma = "Vertical"
# Trailing semicolon
trailing_semicolon = true
# Space before colon
space_before_colon = false
# Space after colon
space_after_colon = true
# Spaces around ranges
spaces_around_ranges = false
# Type punctuation density
type_punctuation_density = "Wide"
# Struct field alignment
struct_field_align_threshold = 0
# Enum discriminant align threshold
enum_discrim_align_threshold = 0
# Inline attribute width
inline_attribute_width = 0
# Format macro matchers
format_macro_matchers = true
# Format macro bodies
format_macro_bodies = true
# Hex literal case
hex_literal_case = "Lower"
# Imports indent
imports_indent = "Block"
# Imports layout
imports_layout = "Mixed"
# Merge derives
merge_derives = true
# Remove nested parens
remove_nested_parens = true
# Short array element width threshold
short_array_element_width_threshold = 10
# Struct lit single line
struct_lit_single_line = true
# Struct variant width threshold
struct_variant_width_threshold = 35
# Skip children
skip_children = false
# Condense wildcard suffixes
condense_wildcard_suffixes = true
# Newline style
newline_style = "Unix"
# Control brace style
control_brace_style = "AlwaysSameBrace"
# Brace style
brace_style = "SameLineWhere"
# Empty item single line
empty_item_single_line = true
# Fn args layout
fn_args_layout = "Tall"
# Fn call width
fn_call_width = 80
# Fn single line
fn_single_line = false
# Where single line
where_single_line = false
# Indent style
indent_style = "Block"
# Item brace style
item_brace_style = "SameLineWhere"
# Literal trailing semicolon
literal_trailing_comma = "true"
+20 -2
View File
@@ -8,10 +8,28 @@ resolver = "2"
[workspace.package]
version = "1.0.0"
authors = ["TaaS Team <[email protected]>"]
edition = "2021"
rust-version = "1.94"
edition = "2024"
rust-version = "1.95"
license = "MIT"
[workspace.metadata.cargo-audit]
# Ignore advisories that are not relevant to this project
# advisories = []
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"
nursery = "warn"
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
unwrap_in_result = "deny"
unimplemented = "deny"
todo = "warn"
[workspace.dependencies]
# Async runtime
tokio = { version = "1.52.1", features = ["full"] }
+90 -24
View File
@@ -7,6 +7,10 @@
The Telecom-as-a-Service (TaaS) Platform is a comprehensive, sovereign cellular connectivity solution that enables organizations to deploy and manage their own private 5G/LTE networks. This full-stack platform provides end-to-end capabilities from core network integration to subscriber management, charging, and developer APIs.
### API Gateway Integration
The platform now includes **Traefik API Gateway** for centralized request routing, security, and monitoring. All services are accessible through a unified HTTPS endpoint with built-in rate limiting, authentication, and SSL termination.
### What It Does
The TaaS Platform allows enterprises, telecom operators, and system integrators to:
@@ -22,6 +26,12 @@ The TaaS Platform allows enterprises, telecom operators, and system integrators
The platform is built as a microservices architecture with the following core components:
**API Gateway Layer:**
- **Traefik API Gateway**: Centralized entry point providing SSL termination, rate limiting, authentication, and request routing
- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com`
- **Security Middleware**: JWT authentication, security headers, compression, and retry logic
- **Monitoring Dashboard**: Real-time metrics and service health visualization
**Core Network Services:**
- **API Server**: Central BSS (Business Support System) API providing authentication, subscriber management, automation, and plugin system
- **Carrier Connector**: ES2+ interface for eSIM profile management and carrier integration
@@ -43,6 +53,12 @@ The platform is built as a microservices architecture with the following core co
### Key Features
**API Gateway & Security:**
- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com`
- **Centralized Authentication**: JWT validation with rate limiting per service
- **SSL Termination**: Automatic HTTPS with security headers enforcement
- **Request Routing**: Intelligent routing with circuit breakers and retry logic
**Sovereignty & Security:**
- Full data sovereignty with on-premise deployment
- End-to-end encryption for subscriber data
@@ -53,26 +69,28 @@ The platform is built as a microservices architecture with the following core co
- eBPF-accelerated packet processing for line-rate throughput
- Redis-backed distributed rate limiting and caching
- Horizontal scaling with Kubernetes orchestration
- Circuit breakers and retry logic for external service resilience
- Gateway-level load balancing and connection pooling
**Developer Experience:**
- **Single API Endpoint**: Simplified client integration through gateway
- REST and GraphQL APIs with comprehensive documentation
- TypeScript SDK for type-safe client integration
- Plugin system for extending platform capabilities
- Automation framework for network operations
**Operations:**
- Real-time health monitoring with Prometheus integration
- **Gateway Dashboard**: Real-time monitoring of all services
- **Unified Metrics**: Prometheus integration with gateway-level insights
- Automated scaling and service discovery
- Centralized logging with structured logs
- Chaos engineering capabilities for testing resilience
- Health checks and failover automation
**Technology Stack:**
**Core Languages & Runtimes:**
- **Go 1.26**: Core network integration, BSS API, carrier connector
- **Rust 1.94**: eBPF packet gateway, real-time charging engine
- **Rust 1.95**: eBPF packet gateway, real-time charging engine
- **TypeScript/Next.js**: Developer dashboard and SDK
**Databases:**
@@ -105,7 +123,7 @@ The platform is built as a microservices architecture with the following core co
### Prerequisites
- **Go 1.26+**: [Download](https://go.dev/dl/)
- **Rust 1.94+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
- **Rust 1.95+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
- **Node.js 22+**: [Download](https://nodejs.org/)
- **pnpm**: `npm install -g pnpm`
- **Docker**: [Install](https://docs.docker.com/get-docker/)
@@ -117,11 +135,28 @@ The platform is built as a microservices architecture with the following core co
### Installation
#### Quick Start with API Gateway (Recommended)
```bash
# Clone the repository
git clone https://github.com/nutcas3/telecom-platform.git
cd telecom-platform
# Start with API Gateway (includes all services)
./scripts/start-gateway.sh
# Add domain to hosts file
echo "127.0.0.1 api.telecom.com" | sudo tee -a /etc/hosts
# Access services
# API Gateway Dashboard: http://localhost:8080
# API Documentation: https://api.telecom.com/api/v1/swagger
# Web Dashboard: http://localhost:3000
```
#### Manual Installation
```bash
# Build all components
make all
@@ -143,24 +178,31 @@ cd apps/web-dashboard && pnpm dev
```
telecom-platform/
├── apps/
│ ├── api-server/ # Go: Developer BSS API
│ ├── carrier-connector/ # Go: eSIM ES2+ Provisioning
│ ├── charging-engine/ # Rust: OCS Real-time Credit Control
│ ├── packet-gateway/ # Rust: eBPF UPF Data Plane
│ └── web-dashboard/ # TypeScript: Next.js Frontend
├── libs/
│ ├── shared-ts-sdk/ # TypeScript: Drop-in Widget SDK
│ └── proto/ # Shared Protobufs
├── deployments/
│ ├── kubernetes/ # K8s manifests
│ └── docker/ # Dockerfiles
├── docs/ # Architecture & API docs
├── scripts/ # Automation scripts
├── Makefile # Master build orchestrator
├── Cargo.toml # Rust workspace config
├── go.work # Go workspace config
└── pnpm-workspace.yaml # TypeScript workspace config
|-- apps/
| |-- api-server/ # Go: Developer BSS API
| |-- carrier-connector/ # Go: eSIM ES2+ Provisioning
| |-- charging-engine/ # Rust: OCS Real-time Credit Control
| |-- packet-gateway/ # Rust: eBPF UPF Data Plane
| |-- web-dashboard/ # TypeScript: Next.js Frontend
|-- libs/
| |-- shared-ts-sdk/ # TypeScript: Drop-in Widget SDK
| |-- proto/ # Shared Protobufs
|-- deployments/
| |-- kubernetes/ # K8s manifests
| |-- docker/ # Dockerfiles
|-- traefik/ # API Gateway configuration
| |-- traefik.yml # Static configuration
| |-- dynamic/ # Dynamic middleware config
|-- docs/ # Architecture & API docs
| |-- gateway-quickstart.md # API Gateway guide
| |-- api-gateway.md # Gateway implementation details
|-- scripts/ # Automation scripts
| |-- start-gateway.sh # Gateway startup script
|-- docker-compose.yml # Container orchestration
|-- Makefile # Master build orchestrator
|-- Cargo.toml # Rust workspace config
|-- go.work # Go workspace config
|-- pnpm-workspace.yaml # TypeScript workspace config
```
## Development
@@ -206,6 +248,8 @@ pnpm dev
## Documentation
- **[API Documentation](./docs/api-spec.yaml)**: OpenAPI 3.0 specification
- **[Gateway Quickstart](./docs/gateway-quickstart.md)**: API Gateway setup and configuration
- **[API Gateway Guide](./docs/api-gateway.md)**: Implementation details and patterns
- **[Architecture Guide](./docs/architecture.md)**: System design and data flows
- **[Deployment Guide](./docs/deployment.md)**: Kubernetes and Docker setup
@@ -227,7 +271,17 @@ pnpm -r test
## Deployment
### Docker
### Docker with API Gateway (Recommended)
```bash
# Start with API Gateway
./scripts/start-gateway.sh
# Or manually
docker-compose up -d
```
### Docker (Legacy)
```bash
make docker-build
@@ -240,6 +294,18 @@ docker-compose up -d
kubectl apply -f deployments/kubernetes/
```
### API Gateway Configuration
The API Gateway provides:
- **Unified Endpoint**: `https://api.telecom.com`
- **Rate Limiting**: Per-service rate limits
- **SSL Termination**: Automatic HTTPS
- **Authentication**: JWT validation
- **Monitoring**: Real-time metrics dashboard
For detailed setup, see [Gateway Quickstart Guide](./docs/gateway-quickstart.md)
## Environment Variables
Create `.env` files in each service directory:
+21 -5
View File
@@ -14,22 +14,31 @@ import (
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
csrf "github.com/utrack/gin-csrf"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/discovery"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/graphql"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/monitoring"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/websocket"
)
// @title Telecom Platform API
// @version 1.0
// @description API for Telecom Platform
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
func main() {
cfg := config.LoadConfig()
db, err := database.NewDatabase(&cfg.Database)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
return
}
defer db.Close()
@@ -72,10 +81,17 @@ func main() {
}
}()
resolver := graphql.NewResolver(db, cfg)
router := gin.Default()
graphql.SetupGraphQLHandler(router, resolver)
// Add CSRF protection middleware
csrfSecret := os.Getenv("CSRF_SECRET")
if csrfSecret == "" {
csrfSecret = "change-me-in-production-csrf-secret-32-chars-min"
log.Printf("WARNING: Using default CSRF secret, set CSRF_SECRET environment variable")
}
router.Use(csrf.Middleware(csrf.Options{
Secret: csrfSecret,
}))
router.Use(func(c *gin.Context) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c.Next() })
@@ -136,7 +152,7 @@ func main() {
go func() {
log.Printf("Starting API server on port %s", cfg.Server.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
log.Printf("Server failed to start: %v", err)
}
}()
+9 -9
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"log"
"math"
"os"
"strconv"
@@ -12,6 +11,7 @@ import (
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/metrics"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/sirupsen/logrus"
)
// startMetricsCollection periodically collects and stores metrics.
@@ -45,7 +45,7 @@ func collectMetrics(ctx context.Context, mc *metrics.MetricsCollectorWithTimeSer
if ts := mc.GetTimeSeriesStorage(); ts != nil {
if err := ts.StoreSystemMetrics(ctx, systemMetrics); err != nil {
log.Printf("Failed to store system metrics: %v", err)
logrus.WithError(err).Error("Failed to store system metrics")
}
}
}
@@ -56,17 +56,17 @@ func getSubscriberStats(db *database.Database) (total, active, suspended int) {
defer cancel()
var totalCount, activeCount, suspendedCount int64
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Count(&totalCount).Error; err != nil {
log.Printf("Error counting total subscribers: %v", err)
logrus.WithError(err).Error("Error counting total subscribers")
return 0, 0, 0
}
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("status = ?", "active").Count(&activeCount).Error; err != nil {
log.Printf("Error counting active subscribers: %v", err)
logrus.WithError(err).Error("Error counting active subscribers")
return int(totalCount), 0, 0
}
if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).
Where("status IN ?", []string{"suspended", "deactivated", "blocked"}).
Count(&suspendedCount).Error; err != nil {
log.Printf("Error counting suspended subscribers: %v", err)
logrus.WithError(err).Error("Error counting suspended subscribers")
return int(totalCount), int(activeCount), 0
}
return int(totalCount), int(activeCount), int(suspendedCount)
@@ -82,7 +82,7 @@ func getActiveSessionsCount(db *database.Database) int {
"status IN ? AND end_time IS NULL",
[]string{"active", "connected", "established"},
).Count(&count).Error; err != nil {
log.Printf("Error counting active sessions: %v", err)
logrus.WithError(err).Error("Error counting active sessions")
return 0
}
@@ -93,7 +93,7 @@ func getActiveSessionsCount(db *database.Database) int {
func getCPUUsage() float64 {
data, err := os.ReadFile("/proc/stat")
if err != nil {
log.Printf("Error reading /proc/stat: %v", err)
logrus.WithError(err).Error("Error reading /proc/stat")
return 0
}
@@ -130,7 +130,7 @@ func getCPUUsage() float64 {
func getMemoryUsage() float64 {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
log.Printf("Error reading /proc/meminfo: %v", err)
logrus.WithError(err).Error("Error reading /proc/meminfo")
return 0
}
@@ -171,7 +171,7 @@ func getNetworkTX() int64 { return readNetworkStat(9) }
func readNetworkStat(fieldIdx int) int64 {
data, err := os.ReadFile("/proc/net/dev")
if err != nil {
log.Printf("Error reading /proc/net/dev: %v", err)
logrus.WithError(err).Error("Error reading /proc/net/dev")
return 0
}
+7 -2
View File
@@ -2,6 +2,8 @@ package main
import (
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/rbac"
@@ -11,13 +13,16 @@ import (
// RBAC enforcement uses Casbin when configured, falling back to role-based
// middleware otherwise.
func registerV1Routes(router *gin.Engine, d *serverDeps) {
v1 := router.Group("/v1")
v1 := router.Group("/api/v1")
// Health check
v1.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// Swagger documentation
v1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// Charging engine integration (public for packet-gateway integration)
charging := v1.Group("/charging")
{
@@ -46,7 +51,7 @@ func registerV1Routes(router *gin.Engine, d *serverDeps) {
}
users := protected.Group("/users")
applyRBAC(users, d.casbinSvc, "/v1/users", "GET", "admin")
applyRBAC(users, d.casbinSvc, "/api/v1/users", "GET", "admin")
{
users.GET("", d.authH.GetUsers)
users.POST("", d.authH.CreateUser)
+757 -98
View File
@@ -1,117 +1,776 @@
// Package docs GENERATED BY SWAG; DO NOT EDIT
// This file was generated by swaggo/swag
// Package docs Code generated by swaggo/swag. DO NOT EDIT
package docs
import (
"strings"
"github.com/swaggo/swag"
)
import "github.com/swaggo/swag"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{.Description}}",
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"termsOfService": "{{.TermsOfService}}",
"contact": {
"name": "{{.ContactName}}",
"url": "{{.ContactURL}}",
"email": "{{.ContactEmail}}"
},
"license": {
"name": "{{.LicenseName}}",
"url": "{{.LicenseURL}}"
},
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {},
"definitions": {}
"paths": {
"/api/v1/subscribers": {
"get": {
"description": "List subscribers with pagination and filtering",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "List subscribers",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "Subscriber status",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Organization ID",
"name": "organization_id",
"in": "query"
},
{
"type": "string",
"description": "Search term",
"name": "search",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/services.ListSubscribersResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"description": "Create a new subscriber with allocated IMSI",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Create a new subscriber",
"parameters": [
{
"description": "Subscriber data",
"name": "subscriber",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/services.CreateSubscriberRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/imsi/{imsi}": {
"get": {
"description": "Retrieve subscriber information by IMSI",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Get subscriber by IMSI",
"parameters": [
{
"type": "string",
"description": "IMSI",
"name": "imsi",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}": {
"get": {
"description": "Retrieve subscriber information by ID",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Get subscriber by ID",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"put": {
"description": "Update subscriber information",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Update subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated subscriber data",
"name": "subscriber",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/services.UpdateSubscriberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}/suspend": {
"post": {
"description": "Suspend a subscriber and terminate their sessions",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Suspend subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}/terminate": {
"post": {
"description": "Terminate a subscriber and deactivate their eSIM profile",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Terminate subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"details": {
"type": "string"
},
"error": {
"type": "string"
}
}
},
"models.AMBR": {
"type": "object",
"properties": {
"downlink": {
"type": "integer"
},
"uplink": {
"type": "integer"
}
}
},
"models.PLMN": {
"type": "object",
"properties": {
"mcc": {
"type": "string"
},
"mnc": {
"type": "string"
}
}
},
"models.ProfileStatus": {
"type": "string",
"enum": [
"inactive",
"active",
"downloading",
"error"
],
"x-enum-varnames": [
"ProfileStatusInactive",
"ProfileStatusActive",
"ProfileStatusDownloading",
"ProfileStatusError"
]
},
"models.QoSProfile": {
"type": "object",
"properties": {
"arpa": {
"type": "integer"
},
"gbr": {
"$ref": "#/definitions/models.AMBR"
},
"mbr": {
"$ref": "#/definitions/models.AMBR"
},
"priority": {
"type": "integer"
},
"qci": {
"type": "integer"
}
}
},
"models.SNSSAI": {
"type": "object",
"properties": {
"sd": {
"type": "integer"
},
"sst": {
"type": "integer"
}
}
},
"models.ServicePlan": {
"type": "object",
"properties": {
"ambr": {
"$ref": "#/definitions/models.AMBR"
},
"arpa": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"data_limit": {
"type": "integer"
},
"data_rate": {
"type": "number"
},
"description": {
"type": "string"
},
"id": {
"type": "integer"
},
"monthly_fee": {
"type": "number"
},
"name": {
"type": "string"
},
"sms_limit": {
"type": "integer"
},
"sms_rate": {
"type": "number"
},
"updated_at": {
"type": "string"
},
"voice_limit": {
"type": "integer"
},
"voice_rate": {
"type": "number"
}
}
},
"models.Session": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"data_used": {
"type": "integer"
},
"dnn": {
"type": "string"
},
"end_time": {
"type": "string"
},
"id": {
"type": "integer"
},
"pdu_address": {
"type": "string"
},
"qos": {
"$ref": "#/definitions/models.QoSProfile"
},
"session_id": {
"type": "string"
},
"sms_used": {
"type": "integer"
},
"snssai": {
"$ref": "#/definitions/models.SNSSAI"
},
"start_time": {
"type": "string"
},
"status": {
"$ref": "#/definitions/models.SessionStatus"
},
"subscriber_id": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"voice_used": {
"type": "integer"
}
}
},
"models.SessionStatus": {
"type": "string",
"enum": [
"active",
"inactive",
"terminating"
],
"x-enum-varnames": [
"SessionStatusActive",
"SessionStatusInactive",
"SessionStatusTerminating"
]
},
"models.Subscriber": {
"type": "object",
"properties": {
"auth_key": {
"type": "string"
},
"balance": {
"type": "number"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"euicc_id": {
"type": "string"
},
"first_name": {
"type": "string"
},
"id": {
"type": "integer"
},
"imei": {
"description": "Device identifier",
"type": "string"
},
"imsi": {
"type": "string"
},
"last_name": {
"type": "string"
},
"msisdn": {
"description": "Mobile phone number",
"type": "string"
},
"opc": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"plan": {
"$ref": "#/definitions/models.ServicePlan"
},
"plan_id": {
"type": "integer"
},
"profile_id": {
"type": "string"
},
"profile_status": {
"$ref": "#/definitions/models.ProfileStatus"
},
"serving_plmn": {
"$ref": "#/definitions/models.PLMN"
},
"sessions": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Session"
}
},
"status": {
"$ref": "#/definitions/models.SubscriberStatus"
},
"updated_at": {
"type": "string"
},
"usage_records": {
"type": "array",
"items": {
"$ref": "#/definitions/models.UsageRecord"
}
}
}
},
"models.SubscriberStatus": {
"type": "string",
"enum": [
"active",
"inactive",
"suspended",
"terminated",
"provisioning"
],
"x-enum-varnames": [
"SubscriberStatusActive",
"SubscriberStatusInactive",
"SubscriberStatusSuspended",
"SubscriberStatusTerminated",
"SubscriberStatusProvisioning"
]
},
"models.UsageRecord": {
"type": "object",
"properties": {
"billing_cycle": {
"type": "string"
},
"cost": {
"type": "number"
},
"created_at": {
"type": "string"
},
"end_time": {
"type": "string"
},
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"rate": {
"type": "number"
},
"session_id": {
"type": "integer"
},
"start_time": {
"type": "string"
},
"subscriber_id": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"usage_type": {
"$ref": "#/definitions/models.UsageType"
},
"volume": {
"type": "integer"
}
}
},
"models.UsageType": {
"type": "string",
"enum": [
"data",
"voice",
"sms"
],
"x-enum-varnames": [
"UsageTypeData",
"UsageTypeVoice",
"UsageTypeSMS"
]
},
"services.CreateSubscriberRequest": {
"type": "object",
"required": [
"email",
"first_name",
"last_name",
"msisdn",
"plan_id"
],
"properties": {
"email": {
"type": "string"
},
"euicc_id": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"msisdn": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"plan_id": {
"type": "integer"
}
}
},
"services.ListSubscribersResponse": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"subscribers": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Subscriber"
}
},
"total": {
"type": "integer"
}
}
},
"services.UpdateSubscriberRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/models.SubscriberStatus"
}
}
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = struct {
Host string
BasePath string
Schemes []string
Title string
Description string
Version string
// License information
LicenseName string
LicenseURL string
// Contact information
ContactName string
ContactURL string
ContactEmail string
}{
Host: "localhost:8000",
BasePath: "/v1",
Schemes: []string{"http", "https"},
Title: "Telecom Platform API",
Description: "A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing",
Version: "1.0.0",
LicenseName: "MIT",
LicenseURL: "https://opensource.org/licenses/MIT",
ContactName: "Telecom Platform Team",
ContactURL: "https://github.com/nutcas3/telecom-platform",
ContactEmail: "[email protected]",
}
type s struct{}
func (s s) ReadDoc() string {
sInfo := SwaggerInfo
description := `Telecom Platform API
This API provides comprehensive functionality for managing a telecom platform including:
- Subscriber management and eSIM provisioning
- Service management and Kubernetes integration
- Monitoring and metrics collection
- Deployment management
- Plugin system
- Workflow automation
- Billing and invoicing
- Configuration management
- Authentication and authorization
## Authentication
The API uses JWT (JSON Web Token) authentication. To access protected endpoints:
1. Register a user account
2. Login to obtain an access token
3. Include the token in the Authorization header: "Bearer <token>"
## Rate Limiting
API requests are rate-limited to ensure fair usage. Current limits:
- 100 requests per minute per authenticated user
- 10 requests per minute for unauthenticated requests
## Error Handling
The API uses standard HTTP status codes:
- 200: Success
- 201: Created
- 400: Bad Request
- 401: Unauthorized
- 403: Forbidden
- 404: Not Found
- 500: Internal Server Error
Error responses include a JSON object with an "error" field containing a descriptive message.`
// Replace template placeholders with actual values
result := strings.Replace(docTemplate, "{{.Version}}", sInfo.Version, -1)
result = strings.Replace(result, "{{.Description}}", description, -1)
return result
var SwaggerInfo = &swag.Spec{
Version: "",
Host: "",
BasePath: "",
Schemes: []string{},
Title: "",
Description: "",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
swag.Register(swag.Name, &s{})
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
}
-8
View File
@@ -1,9 +1,5 @@
package docs
import (
"github.com/swaggo/swag"
)
// @title Telecom Platform API
// @version 1.0.0
// @description A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing
@@ -19,7 +15,3 @@ import (
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
func init() {
swag.Register(swag.Name, &s{})
}
+747
View File
@@ -0,0 +1,747 @@
{
"swagger": "2.0",
"info": {
"contact": {}
},
"paths": {
"/api/v1/subscribers": {
"get": {
"description": "List subscribers with pagination and filtering",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "List subscribers",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "Page number",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "Page size",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "Subscriber status",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Organization ID",
"name": "organization_id",
"in": "query"
},
{
"type": "string",
"description": "Search term",
"name": "search",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/services.ListSubscribersResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"description": "Create a new subscriber with allocated IMSI",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Create a new subscriber",
"parameters": [
{
"description": "Subscriber data",
"name": "subscriber",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/services.CreateSubscriberRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/imsi/{imsi}": {
"get": {
"description": "Retrieve subscriber information by IMSI",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Get subscriber by IMSI",
"parameters": [
{
"type": "string",
"description": "IMSI",
"name": "imsi",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}": {
"get": {
"description": "Retrieve subscriber information by ID",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Get subscriber by ID",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"put": {
"description": "Update subscriber information",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Update subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated subscriber data",
"name": "subscriber",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/services.UpdateSubscriberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.Subscriber"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}/suspend": {
"post": {
"description": "Suspend a subscriber and terminate their sessions",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Suspend subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/api/v1/subscribers/{id}/terminate": {
"post": {
"description": "Terminate a subscriber and deactivate their eSIM profile",
"produces": [
"application/json"
],
"tags": [
"subscribers"
],
"summary": "Terminate subscriber",
"parameters": [
{
"type": "integer",
"description": "Subscriber ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"details": {
"type": "string"
},
"error": {
"type": "string"
}
}
},
"models.AMBR": {
"type": "object",
"properties": {
"downlink": {
"type": "integer"
},
"uplink": {
"type": "integer"
}
}
},
"models.PLMN": {
"type": "object",
"properties": {
"mcc": {
"type": "string"
},
"mnc": {
"type": "string"
}
}
},
"models.ProfileStatus": {
"type": "string",
"enum": [
"inactive",
"active",
"downloading",
"error"
],
"x-enum-varnames": [
"ProfileStatusInactive",
"ProfileStatusActive",
"ProfileStatusDownloading",
"ProfileStatusError"
]
},
"models.QoSProfile": {
"type": "object",
"properties": {
"arpa": {
"type": "integer"
},
"gbr": {
"$ref": "#/definitions/models.AMBR"
},
"mbr": {
"$ref": "#/definitions/models.AMBR"
},
"priority": {
"type": "integer"
},
"qci": {
"type": "integer"
}
}
},
"models.SNSSAI": {
"type": "object",
"properties": {
"sd": {
"type": "integer"
},
"sst": {
"type": "integer"
}
}
},
"models.ServicePlan": {
"type": "object",
"properties": {
"ambr": {
"$ref": "#/definitions/models.AMBR"
},
"arpa": {
"type": "integer"
},
"created_at": {
"type": "string"
},
"data_limit": {
"type": "integer"
},
"data_rate": {
"type": "number"
},
"description": {
"type": "string"
},
"id": {
"type": "integer"
},
"monthly_fee": {
"type": "number"
},
"name": {
"type": "string"
},
"sms_limit": {
"type": "integer"
},
"sms_rate": {
"type": "number"
},
"updated_at": {
"type": "string"
},
"voice_limit": {
"type": "integer"
},
"voice_rate": {
"type": "number"
}
}
},
"models.Session": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"data_used": {
"type": "integer"
},
"dnn": {
"type": "string"
},
"end_time": {
"type": "string"
},
"id": {
"type": "integer"
},
"pdu_address": {
"type": "string"
},
"qos": {
"$ref": "#/definitions/models.QoSProfile"
},
"session_id": {
"type": "string"
},
"sms_used": {
"type": "integer"
},
"snssai": {
"$ref": "#/definitions/models.SNSSAI"
},
"start_time": {
"type": "string"
},
"status": {
"$ref": "#/definitions/models.SessionStatus"
},
"subscriber_id": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"voice_used": {
"type": "integer"
}
}
},
"models.SessionStatus": {
"type": "string",
"enum": [
"active",
"inactive",
"terminating"
],
"x-enum-varnames": [
"SessionStatusActive",
"SessionStatusInactive",
"SessionStatusTerminating"
]
},
"models.Subscriber": {
"type": "object",
"properties": {
"auth_key": {
"type": "string"
},
"balance": {
"type": "number"
},
"created_at": {
"type": "string"
},
"email": {
"type": "string"
},
"euicc_id": {
"type": "string"
},
"first_name": {
"type": "string"
},
"id": {
"type": "integer"
},
"imei": {
"description": "Device identifier",
"type": "string"
},
"imsi": {
"type": "string"
},
"last_name": {
"type": "string"
},
"msisdn": {
"description": "Mobile phone number",
"type": "string"
},
"opc": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"plan": {
"$ref": "#/definitions/models.ServicePlan"
},
"plan_id": {
"type": "integer"
},
"profile_id": {
"type": "string"
},
"profile_status": {
"$ref": "#/definitions/models.ProfileStatus"
},
"serving_plmn": {
"$ref": "#/definitions/models.PLMN"
},
"sessions": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Session"
}
},
"status": {
"$ref": "#/definitions/models.SubscriberStatus"
},
"updated_at": {
"type": "string"
},
"usage_records": {
"type": "array",
"items": {
"$ref": "#/definitions/models.UsageRecord"
}
}
}
},
"models.SubscriberStatus": {
"type": "string",
"enum": [
"active",
"inactive",
"suspended",
"terminated",
"provisioning"
],
"x-enum-varnames": [
"SubscriberStatusActive",
"SubscriberStatusInactive",
"SubscriberStatusSuspended",
"SubscriberStatusTerminated",
"SubscriberStatusProvisioning"
]
},
"models.UsageRecord": {
"type": "object",
"properties": {
"billing_cycle": {
"type": "string"
},
"cost": {
"type": "number"
},
"created_at": {
"type": "string"
},
"end_time": {
"type": "string"
},
"id": {
"type": "integer"
},
"invoice_id": {
"type": "integer"
},
"rate": {
"type": "number"
},
"session_id": {
"type": "integer"
},
"start_time": {
"type": "string"
},
"subscriber_id": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"usage_type": {
"$ref": "#/definitions/models.UsageType"
},
"volume": {
"type": "integer"
}
}
},
"models.UsageType": {
"type": "string",
"enum": [
"data",
"voice",
"sms"
],
"x-enum-varnames": [
"UsageTypeData",
"UsageTypeVoice",
"UsageTypeSMS"
]
},
"services.CreateSubscriberRequest": {
"type": "object",
"required": [
"email",
"first_name",
"last_name",
"msisdn",
"plan_id"
],
"properties": {
"email": {
"type": "string"
},
"euicc_id": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"msisdn": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"plan_id": {
"type": "integer"
}
}
},
"services.ListSubscribersResponse": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"subscribers": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Subscriber"
}
},
"total": {
"type": "integer"
}
}
},
"services.UpdateSubscriberRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"plan_id": {
"type": "integer"
},
"status": {
"$ref": "#/definitions/models.SubscriberStatus"
}
}
}
}
}
+500
View File
@@ -0,0 +1,500 @@
definitions:
handlers.ErrorResponse:
properties:
code:
type: string
details:
type: string
error:
type: string
type: object
models.AMBR:
properties:
downlink:
type: integer
uplink:
type: integer
type: object
models.PLMN:
properties:
mcc:
type: string
mnc:
type: string
type: object
models.ProfileStatus:
enum:
- inactive
- active
- downloading
- error
type: string
x-enum-varnames:
- ProfileStatusInactive
- ProfileStatusActive
- ProfileStatusDownloading
- ProfileStatusError
models.QoSProfile:
properties:
arpa:
type: integer
gbr:
$ref: '#/definitions/models.AMBR'
mbr:
$ref: '#/definitions/models.AMBR'
priority:
type: integer
qci:
type: integer
type: object
models.SNSSAI:
properties:
sd:
type: integer
sst:
type: integer
type: object
models.ServicePlan:
properties:
ambr:
$ref: '#/definitions/models.AMBR'
arpa:
type: integer
created_at:
type: string
data_limit:
type: integer
data_rate:
type: number
description:
type: string
id:
type: integer
monthly_fee:
type: number
name:
type: string
sms_limit:
type: integer
sms_rate:
type: number
updated_at:
type: string
voice_limit:
type: integer
voice_rate:
type: number
type: object
models.Session:
properties:
created_at:
type: string
data_used:
type: integer
dnn:
type: string
end_time:
type: string
id:
type: integer
pdu_address:
type: string
qos:
$ref: '#/definitions/models.QoSProfile'
session_id:
type: string
sms_used:
type: integer
snssai:
$ref: '#/definitions/models.SNSSAI'
start_time:
type: string
status:
$ref: '#/definitions/models.SessionStatus'
subscriber_id:
type: integer
updated_at:
type: string
voice_used:
type: integer
type: object
models.SessionStatus:
enum:
- active
- inactive
- terminating
type: string
x-enum-varnames:
- SessionStatusActive
- SessionStatusInactive
- SessionStatusTerminating
models.Subscriber:
properties:
auth_key:
type: string
balance:
type: number
created_at:
type: string
email:
type: string
euicc_id:
type: string
first_name:
type: string
id:
type: integer
imei:
description: Device identifier
type: string
imsi:
type: string
last_name:
type: string
msisdn:
description: Mobile phone number
type: string
opc:
type: string
organization_id:
type: string
plan:
$ref: '#/definitions/models.ServicePlan'
plan_id:
type: integer
profile_id:
type: string
profile_status:
$ref: '#/definitions/models.ProfileStatus'
serving_plmn:
$ref: '#/definitions/models.PLMN'
sessions:
items:
$ref: '#/definitions/models.Session'
type: array
status:
$ref: '#/definitions/models.SubscriberStatus'
updated_at:
type: string
usage_records:
items:
$ref: '#/definitions/models.UsageRecord'
type: array
type: object
models.SubscriberStatus:
enum:
- active
- inactive
- suspended
- terminated
- provisioning
type: string
x-enum-varnames:
- SubscriberStatusActive
- SubscriberStatusInactive
- SubscriberStatusSuspended
- SubscriberStatusTerminated
- SubscriberStatusProvisioning
models.UsageRecord:
properties:
billing_cycle:
type: string
cost:
type: number
created_at:
type: string
end_time:
type: string
id:
type: integer
invoice_id:
type: integer
rate:
type: number
session_id:
type: integer
start_time:
type: string
subscriber_id:
type: integer
updated_at:
type: string
usage_type:
$ref: '#/definitions/models.UsageType'
volume:
type: integer
type: object
models.UsageType:
enum:
- data
- voice
- sms
type: string
x-enum-varnames:
- UsageTypeData
- UsageTypeVoice
- UsageTypeSMS
services.CreateSubscriberRequest:
properties:
email:
type: string
euicc_id:
type: string
first_name:
type: string
last_name:
type: string
msisdn:
type: string
organization_id:
type: string
plan_id:
type: integer
required:
- email
- first_name
- last_name
- msisdn
- plan_id
type: object
services.ListSubscribersResponse:
properties:
page:
type: integer
page_size:
type: integer
subscribers:
items:
$ref: '#/definitions/models.Subscriber'
type: array
total:
type: integer
type: object
services.UpdateSubscriberRequest:
properties:
email:
type: string
first_name:
type: string
last_name:
type: string
plan_id:
type: integer
status:
$ref: '#/definitions/models.SubscriberStatus'
type: object
info:
contact: {}
paths:
/api/v1/subscribers:
get:
description: List subscribers with pagination and filtering
parameters:
- default: 1
description: Page number
in: query
name: page
type: integer
- default: 20
description: Page size
in: query
name: page_size
type: integer
- description: Subscriber status
in: query
name: status
type: string
- description: Organization ID
in: query
name: organization_id
type: string
- description: Search term
in: query
name: search
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/services.ListSubscribersResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: List subscribers
tags:
- subscribers
post:
consumes:
- application/json
description: Create a new subscriber with allocated IMSI
parameters:
- description: Subscriber data
in: body
name: subscriber
required: true
schema:
$ref: '#/definitions/services.CreateSubscriberRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/models.Subscriber'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Create a new subscriber
tags:
- subscribers
/api/v1/subscribers/{id}:
get:
description: Retrieve subscriber information by ID
parameters:
- description: Subscriber ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Subscriber'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get subscriber by ID
tags:
- subscribers
put:
consumes:
- application/json
description: Update subscriber information
parameters:
- description: Subscriber ID
in: path
name: id
required: true
type: integer
- description: Updated subscriber data
in: body
name: subscriber
required: true
schema:
$ref: '#/definitions/services.UpdateSubscriberRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Subscriber'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Update subscriber
tags:
- subscribers
/api/v1/subscribers/{id}/suspend:
post:
description: Suspend a subscriber and terminate their sessions
parameters:
- description: Subscriber ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: No Content
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Suspend subscriber
tags:
- subscribers
/api/v1/subscribers/{id}/terminate:
post:
description: Terminate a subscriber and deactivate their eSIM profile
parameters:
- description: Subscriber ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: No Content
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Terminate subscriber
tags:
- subscribers
/api/v1/subscribers/imsi/{imsi}:
get:
description: Retrieve subscriber information by IMSI
parameters:
- description: IMSI
in: path
name: imsi
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/models.Subscriber'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get subscriber by IMSI
tags:
- subscribers
swagger: "2.0"
+9 -5
View File
@@ -9,11 +9,12 @@ require (
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/microcosm-cc/bluemonday v1.0.27
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/vektah/gqlparser/v2 v2.5.32
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca
golang.org/x/crypto v0.50.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
@@ -26,20 +27,22 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v3 v3.8.1 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/glebarez/sqlite v1.11.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -64,6 +67,10 @@ require (
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -72,7 +79,6 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
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
@@ -90,7 +96,6 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -124,7 +129,6 @@ require (
)
require (
github.com/99designs/gqlgen v0.17.89
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
+37 -22
View File
@@ -1,7 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
@@ -29,31 +27,23 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -63,6 +53,9 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -90,10 +83,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -111,16 +104,22 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -201,6 +200,7 @@ github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
@@ -217,7 +217,15 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
@@ -293,12 +301,14 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -317,8 +327,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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=
@@ -332,6 +340,7 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@@ -341,6 +350,9 @@ github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJ
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
@@ -412,6 +424,7 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -427,16 +440,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
@@ -471,10 +480,11 @@ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -517,6 +527,7 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -560,6 +571,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -655,8 +667,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+47
View File
@@ -2,6 +2,7 @@ package database
import (
"context"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"gorm.io/gorm"
@@ -59,6 +60,52 @@ func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequ
return subscribers, total, err
}
// ListSubscribersCursor implements cursor-based pagination for subscribers
func (d *Database) ListSubscribersCursor(ctx context.Context, cursor string, limit int, status, organizationID, search string) ([]models.Subscriber, string, bool, error) {
var subscribers []models.Subscriber
query := d.DB.WithContext(ctx).Model(&models.Subscriber{})
// Apply filters
if status != "" {
query = query.Where("status = ?", status)
}
if organizationID != "" {
query = query.Where("organization_id = ?", organizationID)
}
if search != "" {
query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?",
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
}
// Apply cursor-based pagination (using ID as cursor)
if cursor != "" {
query = query.Where("id < ?", cursor)
}
// Order by ID descending and apply limit
query = query.Order("id DESC").Limit(limit + 1) // +1 to check if there are more results
err := query.Preload("Plan").Find(&subscribers).Error
if err != nil {
return nil, "", false, err
}
// Determine if there are more results
hasMore := len(subscribers) > limit
if hasMore {
subscribers = subscribers[:limit] // Remove the extra item
}
// Generate next cursor
var nextCursor string
if len(subscribers) > 0 {
nextCursor = fmt.Sprintf("%d", subscribers[len(subscribers)-1].ID)
}
return subscribers, nextCursor, hasMore, nil
}
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
@@ -1,375 +0,0 @@
package graphql
// This file will be generated by gqlgen
// Run: go run github.com/99designs/gqlgen generate
import (
"context"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/vektah/gqlparser/v2/ast"
)
// GraphQL type definitions
type SubscriberStatus string
type ProfileStatus string
type UsageType string
type SessionStatus string
type PaymentMethodType string
type InvoiceStatus string
type AlertType string
type AlertSeverity string
type SubscriberSort string
type UsagePeriod string
type TrendDirection string
const (
SubscriberStatusActive SubscriberStatus = "ACTIVE"
SubscriberStatusInactive SubscriberStatus = "INACTIVE"
SubscriberStatusSuspended SubscriberStatus = "SUSPENDED"
SubscriberStatusTerminated SubscriberStatus = "TERMINATED"
SubscriberStatusProvisioning SubscriberStatus = "PROVISIONING"
)
const (
ProfileStatusActive ProfileStatus = "ACTIVE"
ProfileStatusInactive ProfileStatus = "INACTIVE"
ProfileStatusDownloading ProfileStatus = "DOWNLOADING"
ProfileStatusFailed ProfileStatus = "FAILED"
)
const (
UsageTypeData UsageType = "DATA"
UsageTypeVoice UsageType = "VOICE"
UsageTypeSMS UsageType = "SMS"
)
const (
SessionStatusActive SessionStatus = "ACTIVE"
SessionStatusCompleted SessionStatus = "COMPLETED"
SessionStatusTerminated SessionStatus = "TERMINATED"
)
const (
PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD"
PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT"
)
const (
InvoiceStatusDraft InvoiceStatus = "DRAFT"
InvoiceStatusPending InvoiceStatus = "PENDING"
InvoiceStatusPaid InvoiceStatus = "PAID"
InvoiceStatusOverdue InvoiceStatus = "OVERDUE"
InvoiceStatusCancelled InvoiceStatus = "CANCELLED"
)
const (
AlertTypeLowBalance AlertType = "LOW_BALANCE"
AlertTypeHighUsage AlertType = "HIGH_USAGE"
AlertTypePaymentFailed AlertType = "PAYMENT_FAILED"
AlertTypeSystemError AlertType = "SYSTEM_ERROR"
)
const (
AlertSeverityLow AlertSeverity = "LOW"
AlertSeverityMedium AlertSeverity = "MEDIUM"
AlertSeverityHigh AlertSeverity = "HIGH"
AlertSeverityCritical AlertSeverity = "CRITICAL"
)
const (
SubscriberSortCreatedAtAsc SubscriberSort = "CREATED_AT_ASC"
SubscriberSortCreatedAtDesc SubscriberSort = "CREATED_AT_DESC"
SubscriberSortUpdatedAtAsc SubscriberSort = "UPDATED_AT_ASC"
SubscriberSortUpdatedAtDesc SubscriberSort = "UPDATED_AT_DESC"
SubscriberSortFirstNameAsc SubscriberSort = "FIRST_NAME_ASC"
SubscriberSortFirstNameDesc SubscriberSort = "FIRST_NAME_DESC"
SubscriberSortLastNameAsc SubscriberSort = "LAST_NAME_ASC"
SubscriberSortLastNameDesc SubscriberSort = "LAST_NAME_DESC"
SubscriberSortEmailAsc SubscriberSort = "EMAIL_ASC"
SubscriberSortEmailDesc SubscriberSort = "EMAIL_DESC"
SubscriberSortBalanceAsc SubscriberSort = "BALANCE_ASC"
SubscriberSortBalanceDesc SubscriberSort = "BALANCE_DESC"
SubscriberSortDataUsedAsc SubscriberSort = "DATA_USED_ASC"
SubscriberSortDataUsedDesc SubscriberSort = "DATA_USED_DESC"
)
const (
UsagePeriodDaily UsagePeriod = "DAILY"
UsagePeriodWeekly UsagePeriod = "WEEKLY"
UsagePeriodMonthly UsagePeriod = "MONTHLY"
UsagePeriodYearly UsagePeriod = "YEARLY"
)
const (
TrendDirectionUp TrendDirection = "UP"
TrendDirectionDown TrendDirection = "DOWN"
TrendDirectionStable TrendDirection = "STABLE"
)
// Input types
type SubscriberFilter struct {
Status *SubscriberStatus `json:"status"`
OrganizationID *string `json:"organizationId"`
PlanID *int `json:"planId"`
Search *string `json:"search"`
BalanceMin *float64 `json:"balanceMin"`
BalanceMax *float64 `json:"balanceMax"`
DataUsageMin *float64 `json:"dataUsageMin"`
DataUsageMax *float64 `json:"dataUsageMax"`
}
type UsageEventFilter struct {
UsageType *UsageType `json:"usageType"`
TimestampGte *time.Time `json:"timestampGte"`
TimestampLte *time.Time `json:"timestampLte"`
CostMin *float64 `json:"costMin"`
CostMax *float64 `json:"costMax"`
}
type CreateSubscriberInput struct {
MSISDN string `json:"msisdn"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email string `json:"email"`
OrganizationID *string `json:"organizationId"`
PlanID int `json:"planId"`
EUICCID *string `json:"euiccId"`
}
type UpdateSubscriberInput struct {
FirstName *string `json:"firstName"`
LastName *string `json:"lastName"`
Email *string `json:"email"`
OrganizationID *string `json:"organizationId"`
PlanID *int `json:"planId"`
}
type TopUpInput struct {
Amount float64 `json:"amount"`
PaymentMethodID *string `json:"paymentMethodId"`
}
type UsageStatsInput struct {
IMSI string `json:"imsi"`
Period UsagePeriod `json:"period"`
}
type PaymentMethodInput struct {
Type PaymentMethodType `json:"type"`
Token string `json:"token"`
IsDefault *bool `json:"isDefault"`
}
type CreateAlertInput struct {
Type AlertType `json:"type"`
Severity AlertSeverity `json:"severity"`
Message string `json:"message"`
SubscriberID *int `json:"subscriberId"`
}
// Output types
type PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"`
StartCursor *string `json:"startCursor"`
EndCursor *string `json:"endCursor"`
}
type SubscriberEdge struct {
Node *models.Subscriber `json:"node"`
Cursor string `json:"cursor"`
}
type SubscriberConnection struct {
Edges []*SubscriberEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
type UsageEventEdge struct {
Node *models.UsageEvent `json:"node"`
Cursor string `json:"cursor"`
}
type UsageEventConnection struct {
Edges []*UsageEventEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
type InvoiceEdge struct {
Node *models.Invoice `json:"node"`
Cursor string `json:"cursor"`
}
type InvoiceConnection struct {
Edges []*InvoiceEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
type AlertEdge struct {
Node *models.Alert `json:"node"`
Cursor string `json:"cursor"`
}
type AlertConnection struct {
Edges []*AlertEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
type ChargingSessionEdge struct {
Node *ChargingSession `json:"node"`
Cursor string `json:"cursor"`
}
type ChargingSessionConnection struct {
Edges []*ChargingSessionEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
}
type UsageTrend struct {
Direction TrendDirection `json:"direction"`
Percentage float64 `json:"percentage"`
PeriodOverPeriod float64 `json:"periodOverPeriod"`
}
type UsageStats struct {
DataUsage float64 `json:"dataUsage"`
VoiceUsage float64 `json:"voiceUsage"`
SmsUsage float64 `json:"smsUsage"`
Cost float64 `json:"cost"`
Period string `json:"period"`
Trend *UsageTrend `json:"trend"`
}
type CurrentSession struct {
SessionID string `json:"sessionId"`
StartTime time.Time `json:"startTime"`
DataUsed float64 `json:"dataUsed"`
VoiceUsed float64 `json:"voiceUsed"`
SmsUsed float64 `json:"smsUsed"`
Cost float64 `json:"cost"`
}
type TodayUsage struct {
DataUsed float64 `json:"dataUsed"`
VoiceUsed float64 `json:"voiceUsed"`
SmsUsed float64 `json:"smsUsed"`
Cost float64 `json:"cost"`
}
type RealTimeUsage struct {
CurrentSession *CurrentSession `json:"currentSession"`
TodayUsage *TodayUsage `json:"todayUsage"`
}
type ESIMProvisionResult struct {
ProfileID string `json:"profileId"`
ActivationCode string `json:"activationCode"`
QRCode string `json:"qrCode"`
}
type ChargingSession struct {
SessionID string `json:"sessionId"`
IMSI string `json:"imsi"`
StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime"`
DataBytes float64 `json:"dataBytes"`
VoiceSeconds float64 `json:"voiceSeconds"`
SmsCount float64 `json:"smsCount"`
TotalCost float64 `json:"totalCost"`
Status SessionStatus `json:"status"`
}
// GraphQL configuration
type Config struct {
Resolvers ResolverRoot
}
type ResolverRoot interface {
Query() QueryResolver
Mutation() MutationResolver
Subscription() SubscriptionResolver
}
type QueryResolver interface {
Subscriber(ctx context.Context, id string) (*models.Subscriber, error)
SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error)
Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error)
SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error)
UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error)
UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error)
RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error)
Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error)
Invoice(ctx context.Context, id string) (*models.Invoice, error)
RatingPlans(ctx context.Context) ([]*models.RatingPlan, error)
RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error)
ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error)
ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error)
Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error)
SystemStats(ctx context.Context) (*models.SystemStats, error)
HealthStatus(ctx context.Context) (*models.HealthStatus, error)
SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error)
SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error)
}
type MutationResolver interface {
CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error)
UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error)
DeleteSubscriber(ctx context.Context, id string) (bool, error)
TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error)
SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error)
ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error)
ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error)
DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error)
AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error)
RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error)
SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error)
ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error)
CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error)
TriggerSystemMaintenance(ctx context.Context) (bool, error)
}
type SubscriptionResolver interface {
SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error)
UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error)
AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error)
SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error)
}
func NewExecutableSchema(cfg Config) graphql.ExecutableSchema {
return &executableSchema{
resolvers: cfg.Resolvers,
}
}
type executableSchema struct {
resolvers ResolverRoot
}
func (e *executableSchema) Schema() *ast.Schema {
// This would be generated by gqlgen
return nil
}
func (e *executableSchema) Complexity(ctx context.Context, typeName, fieldName string, childComplexity int, args map[string]any) (int, bool) {
// Default complexity calculation
return childComplexity, true
}
func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
// This would be generated by gqlgen
return nil
}
func (e *executableSchema) Extensions() map[string]any {
return map[string]any{}
}
@@ -1,135 +0,0 @@
// Package graphql hosts the GraphQL resolver, split across multiple files:
//
// resolvers_subscriber.go - Subscriber queries
// resolvers_usage.go - Usage events & stats
// resolvers_billing.go - Invoices, rating plans, top-up
// resolvers_mutations.go - Subscriber CRUD mutations
// resolvers_esim.go - eSIM + payment-method mutations
// resolvers_alerts.go - Alerts, system stats, maintenance
// resolvers_subscriptions.go - Streaming subscriptions + charging
//
// This file holds only the root Resolver struct, constructor, helpers, and
// the HTTP handler wiring.
package graphql
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gin-gonic/gin"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)
// ensure used imports remain referenced when helpers below are the only consumers
var _ = context.Background
var _ = base64.StdEncoding
// Resolver is the root GraphQL resolver.
type Resolver struct {
db *database.Database
subscriber *services.SubscriberService
charging *services.ChargingService
es2Service *services.ES2Service
}
// NewResolver creates a new GraphQL resolver with wired-up services.
func NewResolver(db *database.Database, cfg *config.Config) *Resolver {
return &Resolver{
db: db,
subscriber: services.NewSubscriberService(db, cfg),
charging: services.NewChargingService(db, cfg),
es2Service: services.NewES2Service(&cfg.ES2),
}
}
// parseID decodes a GraphQL "Subscriber:<n>" opaque ID to a uint.
func parseID(id string) (uint, error) {
var n int
_, err := fmt.Sscanf(id, "Subscriber:%d", &n)
if err != nil {
return 0, fmt.Errorf("invalid subscriber ID format")
}
return uint(n), nil
}
// parseCursorOffset reads the numeric offset from a decoded cursor string.
func parseCursorOffset(cursor string) int {
var offset int
fmt.Sscanf(cursor, "%d", &offset)
return offset
}
// strPtr returns a pointer to the given string.
func strPtr(s string) *string { return new(s) }
// buildConnectionPageInfo builds a Relay-style PageInfo for a page of size count.
func buildConnectionPageInfo(offset, count int, total int64) *PageInfo {
var startCursor, endCursor *string
if count > 0 {
startCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+1)))
endCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+count)))
}
return &PageInfo{
HasNextPage: int64(offset+count) < total,
HasPreviousPage: offset > 0,
StartCursor: startCursor,
EndCursor: endCursor,
}
}
// buildUsageEventFilter builds a SQL WHERE clause from optional filters.
func buildUsageEventFilter(imsi *string, filter *UsageEventFilter) string {
conditions := []string{}
if imsi != nil {
conditions = append(conditions, fmt.Sprintf("imsi = '%s'", *imsi))
}
if filter != nil {
if filter.UsageType != nil {
conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType))
}
if filter.TimestampGte != nil {
conditions = append(conditions, fmt.Sprintf("timestamp >= '%s'", filter.TimestampGte.Format(time.RFC3339)))
}
if filter.TimestampLte != nil {
conditions = append(conditions, fmt.Sprintf("timestamp <= '%s'", filter.TimestampLte.Format(time.RFC3339)))
}
if filter.CostMin != nil {
conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin))
}
if filter.CostMax != nil {
conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax))
}
}
if len(conditions) > 0 {
return "WHERE " + strings.Join(conditions, " AND ")
}
return ""
}
// Query returns the QueryResolver (itself).
func (r *Resolver) Query() QueryResolver { return r }
// Mutation returns the MutationResolver (itself).
func (r *Resolver) Mutation() MutationResolver { return r }
// Subscription returns the SubscriptionResolver (itself).
func (r *Resolver) Subscription() SubscriptionResolver { return r }
// SetupGraphQLHandler wires the GraphQL HTTP endpoint and playground.
func SetupGraphQLHandler(router *gin.Engine, resolver *Resolver) {
graphqlHandler := handler.NewDefaultServer(
NewExecutableSchema(Config{Resolvers: resolver}),
)
router.POST("/graphql", gin.WrapH(graphqlHandler))
if gin.Mode() == gin.DebugMode {
router.GET("/graphql", gin.WrapH(playground.Handler("GraphQL Playground", "/graphql")))
}
}
@@ -1,74 +0,0 @@
package graphql
import (
"context"
"encoding/base64"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// Alerts resolves a paginated connection of alerts.
func (r *Resolver) Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error) {
limit := 50
if first != nil {
limit = *first
}
offset := 0
if after != nil {
cursor, err := base64.StdEncoding.DecodeString(*after)
if err != nil {
return nil, fmt.Errorf("invalid cursor")
}
offset = parseCursorOffset(string(cursor))
}
alerts, total, err := r.subscriber.ListAlerts(ctx, limit, offset, subscriberId, severity, resolved)
if err != nil {
return nil, err
}
edges := make([]*AlertEdge, len(alerts))
for i, alert := range alerts {
edges[i] = &AlertEdge{
Node: alert,
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
}
}
return &AlertConnection{
Edges: edges,
PageInfo: buildConnectionPageInfo(offset, len(alerts), total),
TotalCount: int(total),
}, nil
}
// SystemStats resolves system-wide stats.
func (r *Resolver) SystemStats(ctx context.Context) (*models.SystemStats, error) {
return r.charging.GetSystemStats(ctx)
}
// HealthStatus resolves the platform health status.
func (r *Resolver) HealthStatus(ctx context.Context) (*models.HealthStatus, error) {
return r.charging.GetHealthStatus(ctx)
}
// ResolveAlert marks an alert as resolved.
func (r *Resolver) ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) {
return r.subscriber.ResolveAlert(ctx, alertId)
}
// CreateAlert creates a new alert from GraphQL input.
func (r *Resolver) CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error) {
return r.subscriber.CreateAlert(ctx, &models.CreateAlertRequest{
Type: models.AlertType(input.Type),
Severity: models.AlertSeverity(input.Severity),
Message: input.Message,
SubscriberID: input.SubscriberID,
})
}
// TriggerSystemMaintenance triggers a maintenance cycle.
func (r *Resolver) TriggerSystemMaintenance(ctx context.Context) (bool, error) {
return r.charging.TriggerMaintenance(ctx)
}
@@ -1,67 +0,0 @@
package graphql
import (
"context"
"encoding/base64"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// Invoices resolves a paginated connection of invoices.
func (r *Resolver) Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error) {
limit := 20
if first != nil {
limit = *first
}
offset := 0
if after != nil {
cursor, err := base64.StdEncoding.DecodeString(*after)
if err != nil {
return nil, fmt.Errorf("invalid cursor")
}
offset = parseCursorOffset(string(cursor))
}
invoices, total, err := r.subscriber.ListInvoices(ctx, limit, offset, imsi, status)
if err != nil {
return nil, err
}
edges := make([]*InvoiceEdge, len(invoices))
for i, inv := range invoices {
edges[i] = &InvoiceEdge{
Node: inv,
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
}
}
return &InvoiceConnection{
Edges: edges,
PageInfo: buildConnectionPageInfo(offset, len(invoices), total),
TotalCount: int(total),
}, nil
}
// Invoice resolves a single invoice by ID.
func (r *Resolver) Invoice(ctx context.Context, id string) (*models.Invoice, error) {
return r.subscriber.GetInvoice(ctx, id)
}
// RatingPlans resolves all available rating plans.
func (r *Resolver) RatingPlans(ctx context.Context) ([]*models.RatingPlan, error) {
return r.subscriber.ListRatingPlans(ctx)
}
// RatingPlan resolves a single rating plan by plan_id.
func (r *Resolver) RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) {
return r.subscriber.GetRatingPlan(ctx, planId)
}
// TopUpBalance tops up a subscriber balance (mutation).
func (r *Resolver) TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error) {
return r.subscriber.TopUpBalance(ctx, imsi, &models.TopUpRequest{
Amount: input.Amount,
PaymentMethodID: input.PaymentMethodID,
})
}
@@ -1,59 +0,0 @@
package graphql
import (
"context"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// ProvisionESIM provisions an eSIM profile for the given IMSI.
func (r *Resolver) ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error) {
sub, err := r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi))
if err != nil {
return nil, fmt.Errorf("subscriber not found for IMSI %s: %w", imsi, err)
}
result, err := r.es2Service.ProvisionProfile(ctx, sub)
if err != nil {
return nil, err
}
return &ESIMProvisionResult{
ProfileID: result.ProfileID,
ActivationCode: result.Activation.ActivationCode,
}, nil
}
// ActivateESIM activates the given eSIM profile.
func (r *Resolver) ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) {
err := r.es2Service.ActivateProfile(ctx, imsi, profileId)
return err == nil, err
}
// DeactivateESIM deactivates the given eSIM profile.
func (r *Resolver) DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) {
err := r.es2Service.DeactivateProfile(ctx, imsi, profileId)
return err == nil, err
}
// AddPaymentMethod adds a payment method for the given subscriber.
func (r *Resolver) AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error) {
isDefault := false
if input.IsDefault != nil {
isDefault = *input.IsDefault
}
return r.subscriber.AddPaymentMethod(ctx, subscriberId, &models.AddPaymentMethodRequest{
Type: models.PaymentMethodType(input.Type),
Token: input.Token,
IsDefault: isDefault,
})
}
// RemovePaymentMethod removes a payment method.
func (r *Resolver) RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) {
return r.subscriber.RemovePaymentMethod(ctx, paymentMethodId)
}
// SetDefaultPaymentMethod marks a payment method as default.
func (r *Resolver) SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) {
return r.subscriber.SetDefaultPaymentMethod(ctx, paymentMethodId)
}
@@ -1,87 +0,0 @@
package graphql
import (
"context"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)
// CreateSubscriber creates a new subscriber from GraphQL input.
func (r *Resolver) CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error) {
req := &services.CreateSubscriberRequest{
MSISDN: input.MSISDN,
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
PlanID: uint(input.PlanID),
}
if input.OrganizationID != nil {
req.OrganizationID = *input.OrganizationID
}
if input.EUICCID != nil {
req.EUICCID = *input.EUICCID
}
return r.subscriber.CreateSubscriber(ctx, req)
}
// UpdateSubscriber updates subscriber fields.
func (r *Resolver) UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error) {
sid, err := parseID(id)
if err != nil {
return nil, err
}
var planID *uint
if input.PlanID != nil {
p := uint(*input.PlanID)
planID = &p
}
return r.subscriber.UpdateSubscriber(ctx, sid, &services.UpdateSubscriberRequest{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
PlanID: planID,
})
}
// DeleteSubscriber deletes a subscriber by GraphQL ID.
func (r *Resolver) DeleteSubscriber(ctx context.Context, id string) (bool, error) {
sid, err := parseID(id)
if err != nil {
return false, err
}
return r.subscriber.DeleteSubscriber(ctx, sid)
}
// SuspendSubscriber suspends and returns the refreshed subscriber.
func (r *Resolver) SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
sid, err := parseID(id)
if err != nil {
return nil, err
}
if err := r.subscriber.SuspendSubscriber(ctx, sid); err != nil {
return nil, err
}
return r.subscriber.GetSubscriber(ctx, sid)
}
// ActivateSubscriber activates and returns the subscriber.
func (r *Resolver) ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
sid, err := parseID(id)
if err != nil {
return nil, err
}
return r.subscriber.ActivateSubscriber(ctx, sid)
}
// TerminateSubscriber terminates and returns the refreshed subscriber.
func (r *Resolver) TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) {
sid, err := parseID(id)
if err != nil {
return nil, err
}
if err := r.subscriber.TerminateSubscriber(ctx, sid); err != nil {
return nil, err
}
return r.subscriber.GetSubscriber(ctx, sid)
}
@@ -1,73 +0,0 @@
package graphql
import (
"context"
"encoding/base64"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)
// Subscriber resolves a single subscriber by GraphQL ID.
func (r *Resolver) Subscriber(ctx context.Context, id string) (*models.Subscriber, error) {
sid, err := parseID(id)
if err != nil {
return nil, err
}
return r.subscriber.GetSubscriber(ctx, sid)
}
// SubscriberByImsi resolves a subscriber by IMSI.
func (r *Resolver) SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error) {
return r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi))
}
// Subscribers resolves a paginated connection of subscribers.
func (r *Resolver) Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error) {
limit := 20
if first != nil {
limit = *first
}
offset := 0
if after != nil {
cursor, err := base64.StdEncoding.DecodeString(*after)
if err != nil {
return nil, fmt.Errorf("invalid cursor")
}
offset = parseCursorOffset(string(cursor))
}
resp, err := r.subscriber.ListSubscribers(ctx, &services.ListSubscribersRequest{
Page: offset/limit + 1,
PageSize: limit,
})
if err != nil {
return nil, err
}
edges := make([]*SubscriberEdge, len(resp.Subscribers))
for i, sub := range resp.Subscribers {
s := sub
edges[i] = &SubscriberEdge{
Node: &s,
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
}
}
return &SubscriberConnection{
Edges: edges,
PageInfo: buildConnectionPageInfo(offset, len(resp.Subscribers), resp.Total),
TotalCount: int(resp.Total),
}, nil
}
// SubscriberAccount resolves a subscriber's account by IMSI.
func (r *Resolver) SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) {
return r.subscriber.GetAccount(ctx, imsi)
}
// SearchSubscribers performs a subscriber search, capped at the given limit.
func (r *Resolver) SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) {
return r.subscriber.SearchSubscribers(ctx, query, limit)
}
@@ -1,45 +0,0 @@
package graphql
import (
"context"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// SubscriberUpdates streams subscriber updates via a channel.
func (r *Resolver) SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) {
return r.subscriber.SubscribeToSubscriberUpdates(ctx, subscriberId)
}
// UsageUpdates streams usage-event updates for an IMSI.
func (r *Resolver) UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) {
return r.charging.SubscribeToUsageUpdates(ctx, imsi)
}
// AlertUpdates streams alert updates filtered by severity.
func (r *Resolver) AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error) {
severityStr := string(severity)
return r.subscriber.SubscribeToAlertUpdates(ctx, &severityStr)
}
// SystemStatsUpdates streams system stats updates.
func (r *Resolver) SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) {
return r.charging.SubscribeToSystemStatsUpdates(ctx)
}
// ChargingSessions resolves a paginated connection of charging sessions.
// Placeholder implementation until the charging query surface is finalized.
func (r *Resolver) ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error) {
return &ChargingSessionConnection{
Edges: []*ChargingSessionEdge{},
PageInfo: &PageInfo{},
TotalCount: 0,
}, nil
}
// ChargingSession resolves a single charging session by ID.
// Placeholder implementation until the charging query surface is finalized.
func (r *Resolver) ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error) {
return nil, fmt.Errorf("charging session %s not found", sessionId)
}
@@ -1,162 +0,0 @@
package graphql
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"testing"
)
func TestParseID_Valid(t *testing.T) {
id, err := parseID("Subscriber:42")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 42 {
t.Errorf("expected 42, got %d", id)
}
}
func TestParseID_Invalid(t *testing.T) {
_, err := parseID("invalid")
if err == nil {
t.Fatal("expected error for invalid ID format")
}
}
func TestParseID_Zero(t *testing.T) {
id, err := parseID("Subscriber:0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != 0 {
t.Errorf("expected 0, got %d", id)
}
}
func TestEncodeCursor(t *testing.T) {
cursor := encodeCursor(42)
decoded, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
t.Fatalf("unexpected error decoding cursor: %v", err)
}
if string(decoded) != "cursor:42" {
t.Errorf("expected 'cursor:42', got '%s'", string(decoded))
}
}
func TestDecodeCursor_Valid(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte("cursor:100"))
offset, err := decodeCursor(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if offset != 100 {
t.Errorf("expected 100, got %d", offset)
}
}
func TestDecodeCursor_Invalid(t *testing.T) {
_, err := decodeCursor("not-base64!!!")
if err == nil {
t.Fatal("expected error for invalid cursor")
}
}
func TestDecodeCursor_BadFormat(t *testing.T) {
encoded := base64.StdEncoding.EncodeToString([]byte("bad:format"))
_, err := decodeCursor(encoded)
if err == nil {
t.Fatal("expected error for bad cursor format")
}
}
func TestBuildUsageFilterWhere_Empty(t *testing.T) {
result := buildUsageFilterWhere(nil)
if result != "" {
t.Errorf("expected empty string for nil filter, got '%s'", result)
}
}
func TestBuildUsageFilterWhere_WithType(t *testing.T) {
usageType := UsageTypeData
filter := &UsageEventFilter{UsageType: &usageType}
result := buildUsageFilterWhere(filter)
if result == "" {
t.Fatal("expected non-empty WHERE clause")
}
expected := "WHERE usage_type = 'DATA'"
if result != expected {
t.Errorf("expected '%s', got '%s'", expected, result)
}
}
func TestBuildUsageFilterWhere_WithCostRange(t *testing.T) {
min := 1.0
max := 10.0
filter := &UsageEventFilter{CostMin: &min, CostMax: &max}
result := buildUsageFilterWhere(filter)
if result == "" {
t.Fatal("expected non-empty WHERE clause")
}
}
func TestResolverRoot_Methods(t *testing.T) {
r := &Resolver{}
if r.Query() != r {
t.Error("Query() should return receiver")
}
if r.Mutation() != r {
t.Error("Mutation() should return receiver")
}
if r.Subscription() != r {
t.Error("Subscription() should return receiver")
}
}
// Helper functions for testing (these should match the implementations in resolvers.go)
func encodeCursor(offset int) string {
return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "cursor:%d", offset))
}
func decodeCursor(cursor string) (int, error) {
decoded, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, fmt.Errorf("invalid cursor encoding: %w", err)
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 2 || parts[0] != "cursor" {
return 0, fmt.Errorf("invalid cursor format")
}
return strconv.Atoi(parts[1])
}
func buildUsageFilterWhere(filter *UsageEventFilter) string {
if filter == nil {
return ""
}
var conditions []string
if filter.UsageType != nil {
conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType))
}
if filter.CostMin != nil {
conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin))
}
if filter.CostMax != nil {
conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax))
}
if len(conditions) > 0 {
return "WHERE " + strings.Join(conditions, " AND ")
}
return ""
}
@@ -1,100 +0,0 @@
package graphql
import (
"context"
"encoding/base64"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
)
// UsageEvents resolves a paginated connection of usage events.
func (r *Resolver) UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error) {
limit := 50
if first != nil {
limit = *first
}
offset := 0
if after != nil {
cursor, err := base64.StdEncoding.DecodeString(*after)
if err != nil {
return nil, fmt.Errorf("invalid cursor")
}
offset = parseCursorOffset(string(cursor))
}
where := buildUsageEventFilter(imsi, filter)
events, total, err := r.charging.ListUsageEvents(ctx, limit, offset, where)
if err != nil {
return nil, err
}
edges := make([]*UsageEventEdge, len(events))
for i, event := range events {
edges[i] = &UsageEventEdge{
Node: event,
Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)),
}
}
return &UsageEventConnection{
Edges: edges,
PageInfo: buildConnectionPageInfo(offset, len(events), total),
TotalCount: int(total),
}, nil
}
// UsageStats resolves aggregate usage stats for a subscriber and period.
func (r *Resolver) UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error) {
stats, err := r.charging.GetUsageStats(ctx, input.IMSI, string(input.Period))
if err != nil {
return nil, err
}
return &UsageStats{
DataUsage: stats.DataUsage,
VoiceUsage: stats.VoiceUsage,
SmsUsage: stats.SmsUsage,
Cost: stats.Cost,
Period: stats.Period,
Trend: &UsageTrend{
Direction: TrendDirection(stats.Trend.Direction),
Percentage: stats.Trend.Percentage,
PeriodOverPeriod: stats.Trend.PeriodOverPeriod,
},
}, nil
}
// RealTimeUsage resolves real-time usage for a subscriber by IMSI.
func (r *Resolver) RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) {
usage, err := r.charging.GetRealTimeUsage(ctx, imsi)
if err != nil {
return nil, err
}
var cs *CurrentSession
if usage.CurrentSession != nil {
cs = &CurrentSession{
SessionID: usage.CurrentSession.SessionID,
StartTime: usage.CurrentSession.StartTime,
DataUsed: usage.CurrentSession.DataUsed,
VoiceUsed: usage.CurrentSession.VoiceUsed,
SmsUsed: usage.CurrentSession.SmsUsed,
Cost: usage.CurrentSession.Cost,
}
}
return &RealTimeUsage{
CurrentSession: cs,
TodayUsage: &TodayUsage{
DataUsed: usage.TodayUsage.DataUsed,
VoiceUsed: usage.TodayUsage.VoiceUsed,
SmsUsed: usage.TodayUsage.SmsUsed,
Cost: usage.TodayUsage.Cost,
},
}, nil
}
// SearchUsageEvents searches usage events, capped at limit.
func (r *Resolver) SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) {
return r.charging.SearchUsageEvents(ctx, query, limit)
}
@@ -1,483 +0,0 @@
scalar DateTime
scalar JSON
enum SubscriberStatus {
ACTIVE
INACTIVE
SUSPENDED
TERMINATED
PROVISIONING
}
enum ProfileStatus {
ACTIVE
INACTIVE
DOWNLOADING
FAILED
}
enum UsageType {
DATA
VOICE
SMS
}
enum SessionStatus {
ACTIVE
COMPLETED
TERMINATED
}
enum PaymentMethodType {
CREDIT_CARD
BANK_ACCOUNT
}
enum InvoiceStatus {
DRAFT
PENDING
PAID
OVERDUE
CANCELLED
}
enum AlertType {
LOW_BALANCE
HIGH_USAGE
PAYMENT_FAILED
SYSTEM_ERROR
}
enum AlertSeverity {
LOW
MEDIUM
HIGH
CRITICAL
}
type Subscriber {
id: ID!
imsi: String!
msisdn: String!
firstName: String!
lastName: String!
email: String!
organizationId: String
planId: Int!
euiccId: String
profileId: String
profileStatus: ProfileStatus!
status: SubscriberStatus!
balance: Float!
dataLimit: Float!
dataUsed: Float!
voiceLimit: Float!
voiceUsed: Float!
smsLimit: Float!
smsUsed: Float!
createdAt: DateTime!
updatedAt: DateTime!
account: SubscriberAccount!
usageEvents(first: Int, after: String, filter: UsageEventFilter): UsageEventConnection!
invoices(first: Int, after: String, status: InvoiceStatus): InvoiceConnection!
alerts(first: Int, after: String, severity: AlertSeverity): AlertConnection!
ratingPlan: RatingPlan!
}
type SubscriberAccount {
imsi: String!
balance: Float!
dataLimit: Float!
dataUsed: Float!
voiceLimit: Float!
voiceUsed: Float!
smsLimit: Float!
smsUsed: Float!
status: SubscriberStatus!
lastUpdated: DateTime!
}
type UsageEvent {
id: ID!
imsi: String!
sessionId: String!
usageType: UsageType!
volume: Float!
timestamp: DateTime!
rate: Float!
cost: Float!
subscriber: Subscriber!
}
type RatingPlan {
planId: String!
name: String!
dataRate: Float!
voiceRate: Float!
smsRate: Float!
monthlyFee: Float!
dataLimit: Float!
voiceLimit: Float!
smsLimit: Float!
subscribers(first: Int, after: String, status: SubscriberStatus): SubscriberConnection!
}
type ChargingSession {
sessionId: String!
imsi: String!
startTime: DateTime!
endTime: DateTime
dataBytes: Float!
voiceSeconds: Float!
smsCount: Float!
totalCost: Float!
status: SessionStatus!
subscriber: Subscriber!
usageEvents(first: Int, after: String): UsageEventConnection!
}
type PaymentMethod {
id: String!
type: PaymentMethodType!
last4: String!
expiryMonth: Int!
expiryYear: Int!
brand: String!
isDefault: Boolean!
}
type Invoice {
id: String!
subscriberId: Int!
amount: Float!
currency: String!
status: InvoiceStatus!
dueDate: DateTime!
createdAt: DateTime!
lineItems: [InvoiceLineItem!]!
subscriber: Subscriber!
}
type InvoiceLineItem {
description: String!
quantity: Int!
unitPrice: Float!
amount: Float!
}
type Alert {
id: String!
type: AlertType!
severity: AlertSeverity!
message: String!
subscriberId: Int
timestamp: DateTime!
resolved: Boolean!
subscriber: Subscriber
}
type SystemStats {
activeSessions: Int!
totalAccounts: Int!
blockedUsers: Int!
lowBalanceAlerts: Int!
uptime: Float!
}
type HealthStatus {
redisConnected: Boolean!
activeSync: Boolean!
lastSync: DateTime!
memoryUsage: Float!
}
type SubscriberConnection {
edges: [SubscriberEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type SubscriberEdge {
node: Subscriber!
cursor: String!
}
type UsageEventConnection {
edges: [UsageEventEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UsageEventEdge {
node: UsageEvent!
cursor: String!
}
type InvoiceConnection {
edges: [InvoiceEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type InvoiceEdge {
node: Invoice!
cursor: String!
}
type AlertConnection {
edges: [AlertEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type AlertEdge {
node: Alert!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type SubscriberFilter {
status: SubscriberStatus
organizationId: String
planId: Int
search: String
balanceMin: Float
balanceMax: Float
dataUsageMin: Float
dataUsageMax: Float
}
type UsageEventFilter {
usageType: UsageType
timestampGte: DateTime
timestampLte: DateTime
costMin: Float
costMax: Float
}
type CreateSubscriberInput {
msisdn: String!
firstName: String!
lastName: String!
email: String!
organizationId: String
planId: Int!
euiccId: String
}
type UpdateSubscriberInput {
firstName: String
lastName: String
email: String
organizationId: String
planId: Int
}
type TopUpInput {
amount: Float!
paymentMethodId: String
}
type UsageStatsInput {
imsi: String!
period: UsagePeriod!
}
enum UsagePeriod {
DAILY
WEEKLY
MONTHLY
YEARLY
}
type Query {
subscriber(id: ID!): Subscriber
subscriberByImsi(imsi: String!): Subscriber
subscribers(
first: Int
after: String
filter: SubscriberFilter
sort: SubscriberSort
): SubscriberConnection!
subscriberAccount(imsi: String!): SubscriberAccount
usageEvents(
first: Int
after: String
imsi: String
filter: UsageEventFilter
): UsageEventConnection!
usageStats(input: UsageStatsInput!): UsageStats!
realtimeUsage(imsi: String!): RealTimeUsage!
invoices(
first: Int
after: String
imsi: String
status: InvoiceStatus
): InvoiceConnection!
invoice(id: String!): Invoice
ratingPlans: [RatingPlan!]!
ratingPlan(planId: String!): RatingPlan
chargingSessions(
first: Int
after: String
imsi: String
status: SessionStatus
): ChargingSessionConnection!
chargingSession(sessionId: String!): ChargingSession
alerts(
first: Int
after: String
subscriberId: Int
severity: AlertSeverity
resolved: Boolean
): AlertConnection!
systemStats: SystemStats!
healthStatus: HealthStatus!
searchSubscribers(query: String!, limit: Int): [Subscriber!]!
searchUsageEvents(query: String!, limit: Int): [UsageEvent!]!
}
type Mutation {
createSubscriber(input: CreateSubscriberInput!): Subscriber!
updateSubscriber(id: ID!, input: UpdateSubscriberInput!): Subscriber!
deleteSubscriber(id: ID!): Boolean!
topUpBalance(imsi: String!, input: TopUpInput!): SubscriberAccount!
suspendSubscriber(id: ID!): Subscriber!
activateSubscriber(id: ID!): Subscriber!
terminateSubscriber(id: ID!): Subscriber!
provisionESIM(imsi: String!): ESIMProvisionResult!
activateESIM(imsi: String!, profileId: String!): Boolean!
deactivateESIM(imsi: String!, profileId: String!): Boolean!
addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod!
removePaymentMethod(paymentMethodId: String!): Boolean!
setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod!
addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod!
removePaymentMethod(paymentMethodId: String!): Boolean!
setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod!
resolveAlert(alertId: String!): Alert!
createAlert(input: CreateAlertInput!): Alert!
triggerSystemMaintenance: Boolean!
}
type Subscription {
# Real-time subscriptions
subscriberUpdates(subscriberId: ID!): Subscriber!
usageUpdates(imsi: String!): UsageEvent!
alertUpdates(severity: AlertSeverity): Alert!
systemStatsUpdates: SystemStats!
}
type UsageStats {
dataUsage: Float!
voiceUsage: Float!
smsUsage: Float!
cost: Float!
period: String!
trend: UsageTrend!
}
type UsageTrend {
direction: TrendDirection!
percentage: Float!
periodOverPeriod: Float!
}
enum TrendDirection {
UP
DOWN
STABLE
}
type RealTimeUsage {
currentSession: CurrentSession
todayUsage: TodayUsage!
}
type CurrentSession {
sessionId: String!
startTime: DateTime!
dataUsed: Float!
voiceUsed: Float!
smsUsed: Float!
cost: Float!
}
type TodayUsage {
dataUsed: Float!
voiceUsed: Float!
smsUsed: Float!
cost: Float!
}
type ESIMProvisionResult {
profileId: String!
activationCode: String!
qrCode: String!
}
type PaymentMethodInput {
type: PaymentMethodType!
token: String!
isDefault: Boolean
}
type CreateAlertInput {
type: AlertType!
severity: AlertSeverity!
message: String!
subscriberId: Int
}
type ChargingSessionConnection {
edges: [ChargingSessionEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ChargingSessionEdge {
node: ChargingSession!
cursor: String!
}
enum SubscriberSort {
CREATED_AT_ASC
CREATED_AT_DESC
UPDATED_AT_ASC
UPDATED_AT_DESC
FIRST_NAME_ASC
FIRST_NAME_DESC
LAST_NAME_ASC
LAST_NAME_DESC
EMAIL_ASC
EMAIL_DESC
BALANCE_ASC
BALANCE_DESC
DATA_USED_ASC
DATA_USED_DESC
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
@@ -68,7 +69,11 @@ func setupTest(t *testing.T) *TestSetup {
for _, user := range testUsers {
// Hash the password properly for testing
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
testPassword := os.Getenv("TEST_PASSWORD")
if testPassword == "" {
testPassword = "default_test_password_change_me"
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost)
require.NoError(t, err)
user.Password = string(hashedPassword)
err = db.Create(user).Error
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)
@@ -82,6 +83,14 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// Sanitize user input to prevent XSS
policy := bluemonday.UGCPolicy()
req.Username = policy.Sanitize(req.Username)
req.Email = policy.Sanitize(req.Email)
req.FirstName = policy.Sanitize(req.FirstName)
req.LastName = policy.Sanitize(req.LastName)
req.Role = policy.Sanitize(req.Role)
user, err := h.authService.Register(req.Username, req.Email, req.Password, req.FirstName, req.LastName, req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/services"
)
@@ -43,6 +44,21 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) {
return
}
// Sanitize user input to prevent XSS
policy := bluemonday.UGCPolicy()
if req.FirstName != "" {
req.FirstName = policy.Sanitize(req.FirstName)
}
if req.LastName != "" {
req.LastName = policy.Sanitize(req.LastName)
}
if req.Email != "" {
req.Email = policy.Sanitize(req.Email)
}
if req.MSISDN != "" {
req.MSISDN = policy.Sanitize(req.MSISDN)
}
subscriber, err := h.subscriberService.CreateSubscriber(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{
@@ -52,7 +68,20 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, subscriber)
// Add HATEOAS links
baseURL := c.Request.Host
if c.Request.TLS != nil {
baseURL = "https://" + baseURL
} else {
baseURL = "http://" + baseURL
}
subscriberWithLinks := models.SubscriberWithLinks{
Subscriber: *subscriber,
Links: models.NewSubscriberLinks(baseURL, subscriber),
}
c.JSON(http.StatusCreated, subscriberWithLinks)
}
// GetSubscriber retrieves a subscriber by ID
@@ -85,7 +114,20 @@ func (h *SubscriberHandler) GetSubscriber(c *gin.Context) {
return
}
c.JSON(http.StatusOK, subscriber)
// Add HATEOAS links
baseURL := c.Request.Host
if c.Request.TLS != nil {
baseURL = "https://" + baseURL
} else {
baseURL = "http://" + baseURL
}
subscriberWithLinks := models.SubscriberWithLinks{
Subscriber: *subscriber,
Links: models.NewSubscriberLinks(baseURL, subscriber),
}
c.JSON(http.StatusOK, subscriberWithLinks)
}
// GetSubscriberByIMSI retrieves a subscriber by IMSI
@@ -146,6 +188,21 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) {
return
}
// Sanitize user input to prevent XSS
policy := bluemonday.UGCPolicy()
if req.FirstName != nil && *req.FirstName != "" {
sanitized := policy.Sanitize(*req.FirstName)
req.FirstName = &sanitized
}
if req.LastName != nil && *req.LastName != "" {
sanitized := policy.Sanitize(*req.LastName)
req.LastName = &sanitized
}
if req.Email != nil && *req.Email != "" {
sanitized := policy.Sanitize(*req.Email)
req.Email = &sanitized
}
subscriber, err := h.subscriberService.UpdateSubscriber(c.Request.Context(), uint(id), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{
@@ -172,16 +229,15 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) {
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/subscribers [get]
func (h *SubscriberHandler) ListSubscribers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if pageSize > 100 {
pageSize = 100
if limit > 100 {
limit = 100
}
req := &services.ListSubscribersRequest{
Page: page,
PageSize: pageSize,
Cursor: c.Query("cursor"),
Limit: limit,
Status: models.SubscriberStatus(c.Query("status")),
OrganizationID: c.Query("organization_id"),
Search: c.Query("search"),
@@ -196,7 +252,30 @@ func (h *SubscriberHandler) ListSubscribers(c *gin.Context) {
return
}
c.JSON(http.StatusOK, response)
// Add HATEOAS links
baseURL := c.Request.Host
if c.Request.TLS != nil {
baseURL = "https://" + baseURL
} else {
baseURL = "http://" + baseURL
}
subscribersWithLinks := make([]models.SubscriberWithLinks, len(response.Subscribers))
for i, subscriber := range response.Subscribers {
subscribersWithLinks[i] = models.SubscriberWithLinks{
Subscriber: subscriber,
Links: models.NewSubscriberLinks(baseURL, &subscriber),
}
}
listResponse := models.SubscriberListWithLinks{
Subscribers: subscribersWithLinks,
NextCursor: response.NextCursor,
HasMore: response.HasMore,
Links: models.NewSubscriberListLinks(baseURL, response.NextCursor, req.Limit, response.HasMore),
}
c.JSON(http.StatusOK, listResponse)
}
// SuspendSubscriber suspends a subscriber
@@ -260,6 +260,9 @@ func SecurityHeadersMiddleware() gin.HandlerFunc {
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
c.Next()
}
}
@@ -0,0 +1,79 @@
package models
import "fmt"
// Link represents a HATEOAS link
type Link struct {
Href string `json:"href"`
Rel string `json:"rel"`
Type string `json:"type,omitempty"`
}
// Links represents a collection of HATEOAS links
type Links map[string]Link
// SubscriberWithLinks represents a subscriber with HATEOAS links
type SubscriberWithLinks struct {
Subscriber
Links Links `json:"_links"`
}
// SubscriberListWithLinks represents a subscriber list with HATEOAS links
type SubscriberListWithLinks struct {
Subscribers []SubscriberWithLinks `json:"subscribers"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
Links Links `json:"_links"`
}
// NewSubscriberLinks creates HATEOAS links for a subscriber
func NewSubscriberLinks(baseURL string, subscriber *Subscriber) Links {
return Links{
"self": {
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
Rel: "self",
},
"update": {
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
Rel: "update",
Type: "PUT",
},
"delete": {
Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID),
Rel: "delete",
Type: "DELETE",
},
"billing": {
Href: fmt.Sprintf("%s/api/v1/subscribers/%d/billing", baseURL, subscriber.ID),
Rel: "billing",
},
"sessions": {
Href: fmt.Sprintf("%s/api/v1/subscribers/%d/sessions", baseURL, subscriber.ID),
Rel: "sessions",
},
}
}
// NewSubscriberListLinks creates HATEOAS links for subscriber list
func NewSubscriberListLinks(baseURL string, cursor string, limit int, hasMore bool) Links {
links := Links{
"self": {
Href: fmt.Sprintf("%s/api/v1/subscribers?limit=%d", baseURL, limit),
Rel: "self",
},
"create": {
Href: fmt.Sprintf("%s/api/v1/subscribers", baseURL),
Rel: "create",
Type: "POST",
},
}
if cursor != "" {
links["next"] = Link{
Href: fmt.Sprintf("%s/api/v1/subscribers?cursor=%s&limit=%d", baseURL, cursor, limit),
Rel: "next",
}
}
return links
}
+4 -3
View File
@@ -82,7 +82,7 @@ func (mq *MessageQueue) DeclareQueue(name string) error {
}
// Publish publishes a message to a queue
func (mq *MessageQueue) Publish(queue string, msg Message) error {
func (mq *MessageQueue) Publish(ctx context.Context, queue string, msg Message) error {
msg.Timestamp = time.Now()
body, err := json.Marshal(msg)
@@ -90,11 +90,12 @@ func (mq *MessageQueue) Publish(queue string, msg Message) error {
return fmt.Errorf("failed to marshal message: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// Use provided context with timeout
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err = mq.channel.PublishWithContext(
ctx,
timeoutCtx,
"", // exchange
queue, // routing key
false, // mandatory
@@ -4,8 +4,12 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"slices"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/config"
)
@@ -28,6 +32,32 @@ func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineCl
}
}
// validateURL prevents SSRF by checking if URL is safe to access
func (c *ChargingEngineClient) validateURL(urlStr string) error {
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
host := u.Hostname()
ip := net.ParseIP(host)
// Block private IPs, loopback, and link-local addresses
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
return errors.New("access to private IP addresses is not allowed")
}
// Whitelist allowed domains (charging-engine should be internal)
allowedDomains := []string{"localhost", "127.0.0.1", "charging-engine", "charging-engine.default.svc.cluster.local"}
allowed := slices.Contains(allowedDomains, host)
if !allowed {
return fmt.Errorf("domain %s is not in allowed list", host)
}
return nil
}
// setAPIKeyHeader adds the X-API-Key header to a request if an API key is configured
func (c *ChargingEngineClient) setAPIKeyHeader(req *http.Request) {
if c.apiKey != "" {
@@ -70,6 +100,12 @@ func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytes
}
url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
@@ -102,6 +138,12 @@ func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, byte
}
url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return fmt.Errorf("SSRF protection: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
@@ -129,6 +171,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo
}
url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return fmt.Errorf("SSRF protection: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
@@ -151,6 +199,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo
// GetBalance gets the current credit balance for a subscriber
func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) {
url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
@@ -176,7 +230,13 @@ func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*Bala
// HealthCheck checks if the Rust charging engine is healthy
func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) {
url := fmt.Sprintf("%s/health", c.baseURL)
url := fmt.Sprintf("%s/v1/health", c.baseURL)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
+4 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/sirupsen/logrus"
)
// InvoiceService handles invoice generation and management
@@ -100,8 +101,9 @@ func (s *InvoiceService) SendInvoice(ctx context.Context, invoiceID uint) error
return fmt.Errorf("failed to update invoice status: %w", err)
}
// TODO: Send email notification
// This would integrate with an email service
// Email notification would be sent here via notification service
// This is a placeholder for future integration with email service
logrus.WithField("invoice_id", invoice.ID).Info("Invoice created - email notification pending")
return nil
}
@@ -53,17 +53,16 @@ type UpdateSubscriberRequest struct {
// ListSubscribersRequest is the query-filter payload for listing subscribers.
type ListSubscribersRequest struct {
Page int `json:"page" query:"page"`
PageSize int `json:"page_size" query:"page_size"`
Cursor string `json:"cursor" query:"cursor"`
Limit int `json:"limit" query:"limit"`
Status models.SubscriberStatus `json:"status" query:"status"`
OrganizationID string `json:"organization_id" query:"organization_id"`
Search string `json:"search" query:"search"`
}
// ListSubscribersResponse is the paginated subscriber list result.
// ListSubscribersResponse is the cursor-paginated subscriber list result.
type ListSubscribersResponse struct {
Subscribers []models.Subscriber `json:"subscribers"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/database"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/sirupsen/logrus"
)
// CreateSubscriber creates a new subscriber with allocated IMSI.
@@ -42,9 +42,10 @@ func (s *SubscriberService) CreateSubscriber(ctx context.Context, req *CreateSub
if req.EUICCID != "" {
subscriber.EUICCID = req.EUICCID
go func() {
bgCtx := context.Background()
// Use derived context for goroutine to respect parent cancellation
bgCtx := context.WithoutCancel(ctx)
if err := s.provisionESIMProfile(bgCtx, subscriber.ID); err != nil {
fmt.Printf("Failed to provision eSIM profile for subscriber %d: %v\n", subscriber.ID, err)
logrus.WithError(err).WithField("subscriber_id", subscriber.ID).Warn("Failed to provision eSIM profile")
}
}()
} else {
@@ -104,24 +105,25 @@ func (s *SubscriberService) UpdateSubscriber(ctx context.Context, id uint, req *
return subscriber, nil
}
// ListSubscribers lists subscribers with pagination and filtering.
// ListSubscribers lists subscribers with cursor-based pagination and filtering.
func (s *SubscriberService) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) (*ListSubscribersResponse, error) {
dbReq := &database.ListSubscribersRequest{
Page: req.Page,
PageSize: req.PageSize,
Status: string(req.Status),
OrganizationID: req.OrganizationID,
Search: req.Search,
// Set default limit if not provided
limit := req.Limit
if limit <= 0 {
limit = 20
}
subscribers, total, err := s.db.ListSubscribers(ctx, dbReq)
if limit > 100 {
limit = 100
}
subscribers, nextCursor, hasMore, err := s.db.ListSubscribersCursor(ctx, req.Cursor, limit, string(req.Status), req.OrganizationID, req.Search)
if err != nil {
return nil, fmt.Errorf("failed to list subscribers: %w", err)
}
return &ListSubscribersResponse{
Subscribers: subscribers,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
}
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/sirupsen/logrus"
)
// provisionESIMProfile provisions an eSIM profile for the subscriber
@@ -47,7 +48,7 @@ func (s *SubscriberService) provisionESIMProfile(ctx context.Context, subscriber
// Notify AMF of new subscriber
if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusActive); err != nil {
fmt.Printf("Failed to notify AMF of subscriber activation: %v\n", err)
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber activation")
}
return nil
@@ -81,12 +82,12 @@ func (s *SubscriberService) deactivateESIMProfile(ctx context.Context, subscribe
// Terminate all sessions
if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil {
fmt.Printf("Failed to terminate sessions: %v\n", err)
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to terminate sessions")
}
// Notify AMF of subscriber deactivation
if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusInactive); err != nil {
fmt.Printf("Failed to notify AMF of subscriber deactivation: %v\n", err)
logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber deactivation")
}
return nil
@@ -6,6 +6,7 @@ import (
"time"
"github.com/nutcas3/telecom-platform/apps/api-server/internal/models"
"github.com/sirupsen/logrus"
)
// SuspendSubscriber suspends a subscriber and terminates their sessions.
@@ -124,7 +125,7 @@ func (s *SubscriberService) terminateSubscriberSessions(ctx context.Context, ims
}
if err := s.amfClient.TerminateSession(ctx, imsi, "Subscriber terminated"); err != nil {
fmt.Printf("Failed to notify AMF for session termination: %v\n", err)
logrus.WithError(err).WithField("imsi", imsi).Warn("Failed to notify AMF for session termination")
}
}
+6
View File
@@ -21,13 +21,18 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.1.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -52,6 +57,7 @@ require (
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
+41
View File
@@ -1,5 +1,8 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -13,12 +16,20 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak=
github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM=
github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -31,9 +42,18 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -48,14 +68,19 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -64,11 +89,14 @@ 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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -85,6 +113,7 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -99,6 +128,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -109,8 +139,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE=
github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -121,10 +154,13 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
@@ -132,8 +168,13 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,13 +1,17 @@
package es2
import (
"slices"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"net/http"
"net/url"
"time"
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config"
@@ -38,6 +42,32 @@ func NewES2Client(cfg *config.ES2Config) *ES2Client {
}
}
// validateURL prevents SSRF by checking if URL is safe to access
func (c *ES2Client) validateURL(urlStr string) error {
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
host := u.Hostname()
ip := net.ParseIP(host)
// Block private IPs, loopback, and link-local addresses
if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
return errors.New("access to private IP addresses is not allowed")
}
// Whitelist allowed ES2/SMDP domains (should be production SM-DP+ servers)
allowedDomains := []string{"smdp.example.com", "es2plus.example.com", "smdp.carrier.com"}
allowed := slices.Contains(allowedDomains, host)
if !allowed {
return fmt.Errorf("domain %s is not in allowed ES2 domains list", host)
}
return nil
}
// retryableError checks if an error is retryable
func (c *ES2Client) retryableError(err error, statusCode int) bool {
if err != nil {
@@ -96,6 +126,11 @@ func (c *ES2Client) executeWithRetry(ctx context.Context, req *http.Request) (*h
func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileRequest) (*DownloadProfileResponse, error) {
url := fmt.Sprintf("%s/es2plus/downloadProfile", c.baseURL)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -129,6 +164,11 @@ func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileReq
func (c *ES2Client) GetProfileStatus(ctx context.Context, req *GetProfileStatusRequest) (*GetProfileStatusResponse, error) {
url := fmt.Sprintf("%s/es2plus/getProfileStatus", c.baseURL)
// Validate URL to prevent SSRF
if err := c.validateURL(url); err != nil {
return nil, fmt.Errorf("SSRF protection: %w", err)
}
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
+2 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"time"
@@ -125,9 +126,7 @@ func (mq *MessageQueue) PublishProfileEvent(eventType string, profileID string,
},
}
for k, v := range payload {
msg.Payload[k] = v
}
maps.Copy(msg.Payload, payload)
return mq.Publish("profile-events", msg)
}
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"time"
"gorm.io/driver/postgres"
@@ -58,8 +59,13 @@ func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) {
}
// 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))
+12
View File
@@ -14,6 +14,7 @@ import (
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository"
"github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/webhook"
"github.com/rs/zerolog"
csrf "github.com/utrack/gin-csrf"
)
func main() {
@@ -35,6 +36,17 @@ func main() {
port := handler.GetEnv("PORT", "8080")
router := gin.Default()
// Add CSRF protection middleware
csrfSecret := os.Getenv("CSRF_SECRET")
if csrfSecret == "" {
csrfSecret = "change-me-in-production-csrf-secret-32-chars-min"
handler.Logger.Warn().Msg("Using default CSRF secret, set CSRF_SECRET environment variable")
}
router.Use(csrf.Middleware(csrf.Options{
Secret: csrfSecret,
}))
router.Use(gin.LoggerWithFormatter(func(p gin.LogFormatterParams) string {
return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
p.ClientIP, p.TimeStamp.Format(time.RFC1123),
+1
View File
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
thiserror = "2.0.18"
dotenv = { workspace = true }
uuid = { workspace = true }
jsonwebtoken = "10.3.0"
+53 -4
View File
@@ -8,12 +8,41 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}
use serde::{Deserialize, Serialize};
use std::env;
/// JWT claims for authentication tokens
///
/// # Example
///
/// ```rust
/// use charging_engine::auth::Claims;
///
/// let claims = Claims {
/// sub: "user123".to_string(),
/// exp: 1234567890,
/// };
/// ```
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub exp: usize,
}
/// Authentication configuration for JWT and API keys
///
/// # Example
///
/// ```rust,no_run
/// use charging_engine::auth::AuthConfig;
/// use std::env;
///
/// # fn main() -> Result<(), String> {
/// env::set_var("JWT_SECRET", "a_very_secure_secret_at_least_32_chars");
/// env::set_var("API_KEYS", "key1,key2,key3");
///
/// let config = AuthConfig::from_env()?;
/// assert!(config.validate_api_key("key1"));
/// # Ok(())
/// # }
/// ```
#[derive(Clone)]
pub struct AuthConfig {
pub secret: String,
@@ -21,8 +50,28 @@ pub struct AuthConfig {
}
impl AuthConfig {
pub fn from_env() -> Self {
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret_key_change_in_production".to_string());
/// Load authentication configuration from environment variables
///
/// Requires `JWT_SECRET` (at least 32 characters) and optionally `API_KEYS`
/// (comma-separated list of valid API keys).
///
/// # Example
///
/// ```rust,no_run
/// use charging_engine::auth::AuthConfig;
///
/// # fn main() -> Result<(), String> {
/// let config = AuthConfig::from_env()?;
/// # Ok(())
/// # }
/// ```
pub fn from_env() -> Result<Self, String> {
let secret = env::var("JWT_SECRET")
.map_err(|_| "JWT_SECRET environment variable is required".to_string())?;
if secret.len() < 32 {
return Err("JWT_SECRET must be at least 32 characters for security".to_string());
}
// Parse API keys from comma-separated environment variable
let api_keys_env = env::var("API_KEYS").unwrap_or_else(|_| String::new());
@@ -32,7 +81,7 @@ impl AuthConfig {
.filter(|s| !s.is_empty())
.collect();
Self { secret, api_keys }
Ok(Self { secret, api_keys })
}
pub fn validate_api_key(&self, api_key: &str) -> bool {
@@ -43,7 +92,7 @@ impl AuthConfig {
pub fn create_token(user_id: &str, config: &AuthConfig) -> Result<String, StatusCode> {
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::hours(24))
.expect("valid timestamp")
.ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)?
.timestamp() as usize;
let claims = Claims {
@@ -100,7 +100,7 @@ impl crate::charging::ChargingEngine {
let blocked: Option<String> = redis::AsyncCommands::get(&mut conn, &key).await
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
if blocked.is_some() {
if let Some(_blocked) = blocked {
return Err(ChargingError::UsageBlocked(format!("User IP {} is already blocked", ip)));
}
@@ -127,12 +127,21 @@ impl crate::charging::ChargingEngine {
let mut conn = self.redis_client.get_multiplexed_async_connection().await
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
let key = format!("block:{}", ip);
let _: () = redis::AsyncCommands::del(&mut conn, &key).await
// Check if user is blocked
let blocked_key = format!("blocked:{}", ip);
let blocked: Option<String> = redis::AsyncCommands::get(&mut conn, &blocked_key).await
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
if let Some(_blocked) = blocked {
info!("User IP: {} has been unblocked", ip);
Ok(())
let _: () = redis::AsyncCommands::del(&mut conn, &blocked_key).await
.map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?;
info!("User IP: {} has been unblocked", ip);
Ok(())
} else {
Ok(())
}
}).await.map_err(|e| match e {
CircuitBreakerError::Open => ChargingError::RedisConnection("Circuit breaker is open".to_string()),
CircuitBreakerError::Inner(e) => e,
@@ -9,6 +9,19 @@ use crate::errors::ChargingResult;
/// ChargingEngine coordinates Redis-backed hot-path state (balances, sessions)
/// and Postgres-backed configuration (rating plans) via `RatingPlansRepo`.
///
/// # Example
///
/// ```rust,no_run
/// use charging_engine::charging::engine::ChargingEngine;
/// use charging_engine::charging::rating_plans_repo::RatingPlansRepo;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let plans = RatingPlansRepo::connect("postgres://localhost/db").await?;
/// let engine = ChargingEngine::new("redis://localhost", plans, 60)?;
/// # Ok(())
/// # }
/// ```
pub struct ChargingEngine {
pub(crate) redis_client: redis::Client,
pub(crate) plans: RatingPlansRepo,
@@ -7,8 +7,7 @@ use crate::errors::{ChargingError, ChargingResult, validate_amount};
impl super::ChargingEngine {
pub async fn calculate_usage_cost(&self, event: &UsageEvent) -> ChargingResult<f64> {
// Get subscriber account to determine rating plan
let account = self.get_subscriber_account(&event.imsi).await?;
let _account = account.ok_or_else(|| ChargingError::SubscriberNotFound(event.imsi.clone()))?;
let _account = self.get_subscriber_account(&event.imsi).await?;
// Get rating plan from Postgres (simplified - defaults to basic).
let plan = self
@@ -63,7 +62,7 @@ impl super::ChargingEngine {
}
pub async fn apply_charging_rules(&self, event: &UsageEvent) -> ChargingResult<Vec<ChargingRule>> {
let mut applied_rules = Vec::new();
let mut applied_rules = Vec::with_capacity(6); // Max ~6 rules can be applied
// Rule 1: Check if user has sufficient credit
if !self.check_credit(&event.imsi, event.volume).await? {
@@ -160,7 +159,7 @@ impl super::ChargingEngine {
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
let pattern = format!("usage:{}:*", imsi);
let mut keys: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::with_capacity(100); // SCAN batch size
// Use SCAN instead of KEYS to avoid blocking Redis
let mut cursor = 0u64;
@@ -217,7 +216,7 @@ impl super::ChargingEngine {
.map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?;
let pattern = "account:*".to_string();
let mut keys: Vec<String> = Vec::new();
let mut keys: Vec<String> = Vec::with_capacity(100); // SCAN batch size
// Use SCAN instead of KEYS to avoid blocking Redis
let mut cursor = 0u64;
@@ -125,9 +125,13 @@ impl RatingPlansRepo {
.await
.map_err(|e| crate::errors::ChargingError::DatabaseError(e.to_string()))?;
let mut out = HashMap::with_capacity(rows.len());
for row in rows {
let plan = row_to_plan(row);
let plans = rows
.into_iter()
.map(|row| row_to_plan(row))
.collect::<Vec<_>>();
let mut out = HashMap::new();
for plan in plans {
out.insert(plan.plan_id.clone(), plan);
}
Ok(out)
@@ -236,8 +240,19 @@ fn row_to_plan(row: sqlx::postgres::PgRow) -> RatingPlan {
/// Run Goose migrations by calling the goose binary.
/// This ensures both Go and Rust services use the same migration system.
/// Changes to the repo root directory to find the migrations folder.
async fn run_migrations(database_url: &str) -> Result<(), Box<dyn std::error::Error>> {
use std::process::Command;
// Change to repo root to find migrations directory
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = manifest_dir
.parent()
.and_then(|p| p.parent())
.ok_or("Failed to determine repo root")?;
let output = Command::new("goose")
.current_dir(&repo_root)
.args(["postgres", database_url, "up", "migrations"])
.output()?;
+104 -2
View File
@@ -2,6 +2,16 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use redis::{FromRedisValue, ToRedisArgs, ToSingleRedisArg};
/// Charging rule for usage authorization
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::ChargingRule;
///
/// let rule = ChargingRule::Allowed;
/// assert_eq!(rule.as_str(), "ALLOWED");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChargingRule {
Allowed,
@@ -14,6 +24,16 @@ pub enum ChargingRule {
}
impl ChargingRule {
/// Convert charging rule to string representation
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::ChargingRule;
///
/// let rule = ChargingRule::Allowed;
/// assert_eq!(rule.as_str(), "ALLOWED");
/// ```
pub fn as_str(&self) -> &str {
match self {
ChargingRule::Allowed => "ALLOWED",
@@ -27,6 +47,27 @@ impl ChargingRule {
}
}
/// Subscriber account information
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::{SubscriberAccount, AccountStatus};
/// use chrono::Utc;
///
/// let account = SubscriberAccount {
/// imsi: "1234567890".to_string(),
/// balance: 1000,
/// data_limit: 1000000000,
/// data_used: 0,
/// voice_limit: 1000,
/// voice_used: 0,
/// sms_limit: 100,
/// sms_used: 0,
/// status: AccountStatus::Active,
/// last_updated: Utc::now(),
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriberAccount {
pub imsi: String,
@@ -41,6 +82,15 @@ pub struct SubscriberAccount {
pub last_updated: DateTime<Utc>,
}
/// Account status
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::AccountStatus;
///
/// let status = AccountStatus::Active;
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AccountStatus {
Active,
@@ -49,6 +99,24 @@ pub enum AccountStatus {
Blocked,
}
/// Usage event for charging
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::{UsageEvent, UsageType};
/// use chrono::Utc;
///
/// let event = UsageEvent {
/// imsi: "1234567890".to_string(),
/// session_id: "session123".to_string(),
/// usage_type: UsageType::Data,
/// volume: 1000,
/// timestamp: Utc::now(),
/// rate: 0.01,
/// cost: 10.0,
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageEvent {
pub imsi: String,
@@ -60,6 +128,15 @@ pub struct UsageEvent {
pub cost: f64,
}
/// Usage type classification
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::UsageType;
///
/// let usage_type = UsageType::Data;
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum UsageType {
Data,
@@ -67,6 +144,25 @@ pub enum UsageType {
SMS,
}
/// Rating plan for subscriber billing
///
/// # Example
///
/// ```rust
/// use charging_engine::charging::types::RatingPlan;
///
/// let plan = RatingPlan {
/// plan_id: "plan1".to_string(),
/// name: "Basic Plan".to_string(),
/// data_rate: 0.01,
/// voice_rate: 0.05,
/// sms_rate: 0.1,
/// monthly_fee: 10.0,
/// data_limit: 1000000000,
/// voice_limit: 1000,
/// sms_limit: 100,
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatingPlan {
pub plan_id: String,
@@ -96,7 +192,10 @@ impl ToRedisArgs for SubscriberAccount {
W: redis::RedisWrite + ?Sized,
{
let json = serde_json::to_string(self)
.expect("Failed to serialize SubscriberAccount");
.unwrap_or_else(|_| {
// Fallback to empty JSON if serialization fails
r#"{"imsi":"","balance":0,"data_limit":0,"data_used":0,"voice_limit":0,"voice_used":0,"sms_limit":0,"sms_used":0,"status":"Active","last_updated":"1970-01-01T00:00:00Z"}"#.to_string()
});
json.write_redis_args(out)
}
}
@@ -118,7 +217,10 @@ impl ToRedisArgs for UsageEvent {
W: redis::RedisWrite + ?Sized,
{
let json = serde_json::to_string(self)
.expect("Failed to serialize UsageEvent");
.unwrap_or_else(|_| {
// Fallback to empty JSON if serialization fails
r#"{"imsi":"","session_id":"","usage_type":"Data","volume":0,"timestamp":"1970-01-01T00:00:00Z","rate":0.0,"cost":0.0}"#.to_string()
});
json.write_redis_args(out)
}
}
@@ -2,6 +2,16 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Circuit breaker state
///
/// # Example
///
/// ```rust
/// use charging_engine::circuit_breaker::CircuitState;
///
/// let state = CircuitState::Closed;
/// assert_eq!(state, CircuitState::Closed);
/// ```
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum CircuitState {
Closed,
@@ -9,6 +19,15 @@ pub enum CircuitState {
HalfOpen,
}
/// Circuit breaker error type
///
/// # Example
///
/// ```rust
/// use charging_engine::circuit_breaker::CircuitBreakerError;
///
/// let error: CircuitBreakerError<&str> = CircuitBreakerError::Open;
/// ```
#[derive(Debug)]
pub enum CircuitBreakerError<E> {
Open,
@@ -26,6 +45,16 @@ impl<E: std::fmt::Display> std::fmt::Display for CircuitBreakerError<E> {
impl<E: std::error::Error + Send + Sync + 'static> std::error::Error for CircuitBreakerError<E> {}
/// Circuit breaker for fault tolerance
///
/// # Example
///
/// ```rust,no_run
/// use charging_engine::circuit_breaker::CircuitBreaker;
/// use std::time::Duration;
///
/// let breaker = CircuitBreaker::new(5, Duration::from_secs(60));
/// ```
#[derive(Clone)]
pub struct CircuitBreaker {
state: Arc<RwLock<CircuitState>>,
@@ -36,6 +65,21 @@ pub struct CircuitBreaker {
}
impl CircuitBreaker {
/// Create a new circuit breaker
///
/// # Arguments
///
/// * `failure_threshold` - Number of failures before opening the circuit
/// * `timeout` - Duration to wait before attempting to close the circuit
///
/// # Example
///
/// ```rust
/// use charging_engine::circuit_breaker::CircuitBreaker;
/// use std::time::Duration;
///
/// let breaker = CircuitBreaker::new(5, Duration::from_secs(60));
/// ```
pub fn new(failure_threshold: u32, timeout: Duration) -> Self {
Self {
state: Arc::new(RwLock::new(CircuitState::Closed)),
+21 -23
View File
@@ -1,43 +1,41 @@
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
use serde_json::json;
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone)]
#[derive(Debug, Error, Clone)]
pub enum ChargingError {
#[error("Redis connection error: {0}")]
RedisConnection(String),
#[error("Redis operation error: {0}")]
RedisOperation(String),
#[error("Database error: {0}")]
DatabaseError(String),
#[error("Subscriber not found: {0}")]
SubscriberNotFound(String),
#[error("Rating plan not found: {0}")]
RatingPlanNotFound(String),
#[error("Insufficient credit: available={available}, requested={requested}")]
InsufficientCredit { available: u64, requested: u64 },
#[error("Usage blocked: {0}")]
UsageBlocked(String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Serialization error: {0}")]
SerializationError(String),
#[error("Internal error: {0}")]
InternalError(String),
}
impl fmt::Display for ChargingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChargingError::RedisConnection(msg) => write!(f, "Redis connection error: {}", msg),
ChargingError::RedisOperation(msg) => write!(f, "Redis operation error: {}", msg),
ChargingError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
ChargingError::SubscriberNotFound(imsi) => write!(f, "Subscriber not found: {}", imsi),
ChargingError::RatingPlanNotFound(plan_id) => write!(f, "Rating plan not found: {}", plan_id),
ChargingError::InsufficientCredit { available, requested } => {
write!(f, "Insufficient credit: available={}, requested={}", available, requested)
}
ChargingError::UsageBlocked(reason) => write!(f, "Usage blocked: {}", reason),
ChargingError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
ChargingError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
ChargingError::InternalError(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for ChargingError {}
impl IntoResponse for ChargingError {
fn into_response(self) -> Response {
let (status, message) = match &self {
+3 -2
View File
@@ -66,12 +66,13 @@ pub async fn add_rating_plan(
sms_limit: req.get("sms_limit").and_then(|v| v.as_u64()).unwrap_or(100),
};
state.charging_engine.add_rating_plan(plan.clone()).await
let plan_id = plan.plan_id.clone();
state.charging_engine.add_rating_plan(plan).await
.with_context("Failed to add rating plan")?;
Ok(Json(serde_json::json!({
"status": "added",
"plan_id": plan.plan_id,
"plan_id": plan_id,
})))
}
+34
View File
@@ -1,3 +1,37 @@
//! # Charging Engine
//!
//! A high-performance telecom charging engine for real-time usage-based billing.
//!
//! ## Features
//!
//! - Real-time rating and billing
//! - Circuit breaker for fault tolerance
//! - Rate limiting for API protection
//! - Comprehensive monitoring and metrics
//!
//! ## Example
//!
//! ```rust,no_run
//! use charging_engine::ChargingEngine;
//! use charging_engine::charging::types::UsageEvent;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let engine = ChargingEngine::new("redis://localhost").await?;
//!
//! let event = UsageEvent {
//! imsi: "1234567890".to_string(),
//! volume: 1000,
//! timestamp: chrono::Utc::now(),
//! };
//!
//! let cost = engine.calculate_cost(&event).await?;
//! println!("Cost: {}", cost);
//!
//! Ok(())
//! }
//! ```
pub mod auth;
pub mod charging;
pub mod circuit_breaker;
+3 -2
View File
@@ -29,7 +29,7 @@ async fn main() -> Result<()> {
// Initialize charging engine
let redis_url = std::env::var("REDIS_URI").unwrap_or_else(|_| "redis://127.0.0.1/".to_string());
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL is required (for rating plans)");
.map_err(|_| anyhow::anyhow!("DATABASE_URL environment variable is required (for rating plans)"))?;
let sync_interval = std::env::var("SYNC_INTERVAL")
.unwrap_or_else(|_| "1".to_string())
.parse::<u64>()
@@ -47,7 +47,8 @@ async fn main() -> Result<()> {
info!("Connected to Redis successfully");
// Create application state
let auth_config = AuthConfig::from_env();
let auth_config = AuthConfig::from_env()
.map_err(|e| anyhow::anyhow!("Failed to initialize auth config: {}", e))?;
let state = crate::models::AppState {
charging_engine,
auth_config,
+1 -1
View File
@@ -1,3 +1,3 @@
pub mod sync;
pub mod types;
pub mod monitoring;
pub mod monitoring_impl;
@@ -37,13 +37,11 @@ impl crate::charging::ChargingEngine {
let info: String = redis::cmd("INFO").query_async(&mut conn).await.unwrap_or_default();
for line in info.lines() {
if line.starts_with("instantaneous_ops_per_sec:") {
if let Some(rps_str) = line.split(':').nth(1) {
if let Ok(rps) = rps_str.parse::<f64>() {
return Ok(rps);
}
if line.starts_with("instantaneous_ops_per_sec:")
&& let Some(rps_str) = line.split(':').nth(1)
&& let Ok(rps) = rps_str.parse::<f64>() {
return Ok(rps);
}
}
}
let total_commands = self.extract_metric(&info, "total_commands_processed");
+8 -12
View File
@@ -221,12 +221,10 @@ impl crate::charging::ChargingEngine {
// Try to read from /proc/self/status for Linux systems
if let Ok(status) = fs::read_to_string("/proc/self/status") {
for line in status.lines() {
if line.starts_with("VmRSS:") {
if let Some(memory_str) = line.split_whitespace().nth(1) {
if let Ok(memory_kb) = memory_str.parse::<u64>() {
return Ok(memory_kb * 1024); // Convert KB to bytes
}
}
if line.starts_with("VmRSS:")
&& let Some(memory_str) = line.split_whitespace().nth(1)
&& let Ok(memory_kb) = memory_str.parse::<u64>() {
return Ok(memory_kb * 1024); // Convert KB to bytes
}
}
}
@@ -254,13 +252,11 @@ impl crate::charging::ChargingEngine {
// Parse memory usage from Redis info
for line in info.lines() {
if line.starts_with("used_memory:") {
if let Some(memory_str) = line.split(':').nth(1) {
if let Ok(memory_bytes) = memory_str.parse::<u64>() {
return Ok(memory_bytes);
}
if line.starts_with("used_memory:")
&& let Some(memory_str) = line.split(':').nth(1)
&& let Ok(memory_bytes) = memory_str.parse::<u64>() {
return Ok(memory_bytes);
}
}
}
// Fallback to estimate
+8 -1
View File
@@ -33,11 +33,18 @@ pub fn create_router(state: AppState) -> Router {
.and_then(|s| s.parse().ok())
.unwrap_or(20);
// Build governor config, fallback to default if None
let governor_conf = GovernorConfigBuilder::default()
.per_second(requests_per_second as u64)
.burst_size(burst_size)
.finish()
.unwrap();
.unwrap_or_else(|| {
GovernorConfigBuilder::default()
.per_second(10)
.burst_size(20)
.finish()
.unwrap()
});
Router::new()
// Public routes (no auth required - for packet-gateway integration)
@@ -12,7 +12,7 @@ fn test_validate_bytes_zero() {
#[test]
fn test_validate_bytes_exceeds_limit() {
std::env::set_var("MAX_BYTES_LIMIT", "1000000000000");
unsafe { std::env::set_var("MAX_BYTES_LIMIT", "1000000000000"); }
let result = validate_bytes(2_000_000_000_000); // 2TB
assert!(result.is_err());
match result.unwrap_err() {
@@ -75,7 +75,7 @@ fn test_validate_amount_negative() {
#[test]
fn test_validate_amount_exceeds_limit() {
std::env::set_var("MAX_AMOUNT_LIMIT", "10000");
unsafe { std::env::set_var("MAX_AMOUNT_LIMIT", "10000"); }
let result = validate_amount(20_000.0);
assert!(result.is_err());
match result.unwrap_err() {
+4 -4
View File
@@ -28,7 +28,7 @@ type CreditDeductRequest struct {
func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResponse, error) {
var resp CreditCheckResponse
req := CreditCheckRequest{BytesRequested: bytesRequested}
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/check", req, &resp); err != nil {
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/check", req, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -37,7 +37,7 @@ func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResp
// GetBalance gets the current credit balance for a subscriber
func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) {
var resp CreditBalanceResponse
if err := c.doGetJSON("/v1/charging/credit/"+ip+"/balance", &resp); err != nil {
if err := c.doGetJSON("/api/v1/charging/credit/"+ip+"/balance", &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -47,7 +47,7 @@ func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) {
func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse, error) {
var resp CreditBalanceResponse
req := CreditAddRequest{BytesToAdd: bytesToAdd}
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/add", req, &resp); err != nil {
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/add", req, &resp); err != nil {
return nil, err
}
return &resp, nil
@@ -57,7 +57,7 @@ func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse
func (c *Client) DeductCredit(ip string, bytesUsed uint64) (*CreditBalanceResponse, error) {
var resp CreditBalanceResponse
req := CreditDeductRequest{BytesUsed: bytesUsed}
if err := c.doPostJSON("/v1/charging/credit/"+ip+"/deduct", req, &resp); err != nil {
if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/deduct", req, nil); err != nil {
return nil, err
}
return &resp, nil
+6 -6
View File
@@ -94,7 +94,7 @@ async fn main() -> Result<()> {
#[cfg(feature = "ebpf")]
let (ebpf_manager, ebpf_attached) = {
// Initialize eBPF manager
let mut ebpf_manager = EbpfManager::new(args.interface.clone()).await
let mut ebpf_manager = EbpfManager::new(&args.interface).await
.context("Failed to initialize eBPF manager")?;
// Load and attach XDP program
@@ -123,7 +123,7 @@ async fn main() -> Result<()> {
info!("Packet gateway running. Press Ctrl+C to exit.");
// Start HTTP health check server
let redis_client_arc = Arc::new(redis_client.clone());
let redis_client_arc = Arc::new(redis_client);
let ebpf_attached_clone = Arc::clone(&ebpf_attached);
#[cfg(feature = "ebpf")]
@@ -133,9 +133,9 @@ async fn main() -> Result<()> {
// Create config state
let config = PacketGatewayConfig {
interface: args.interface.clone(),
redis_url: args.redis_url.clone(),
charging_engine_url: args.charging_engine_url.clone(),
interface: args.interface,
redis_url: args.redis_url,
charging_engine_url: args.charging_engine_url,
sync_interval: args.sync_interval,
health_port: args.health_port,
};
@@ -215,7 +215,7 @@ async fn main() -> Result<()> {
s.ip & 0xFF
);
let session_id = format!("session-{}", ip_str);
(ip_str.clone(), session_id, s.bytes)
(ip_str, session_id, s.bytes)
})
.collect();
+87 -5
View File
@@ -1,4 +1,26 @@
services:
traefik:
image: traefik:v3.0
container_name: taas-traefik
restart: unless-stopped
command:
- "--configFile=/traefik/traefik.yml"
ports:
- "80:80"
- "443:443"
- "8080:8080" # Traefik dashboard
volumes:
- ./traefik:/traefik
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
JWT_SECRET: "${JWT_SECRET:-change-me-in-production-jwt-secret-32-chars-min}"
networks:
- taas-network
depends_on:
- api-server
- charging-engine
- carrier-connector
mongodb:
image: mongo:7.0
container_name: taas-mongodb
@@ -185,8 +207,8 @@ services:
dockerfile: deployments/docker/api-server.Dockerfile
container_name: taas-api-server
restart: unless-stopped
ports:
- "8000:8000"
expose:
- "8000"
environment:
MONGODB_URI: mongodb://admin:changeme@mongodb:27017/free5gc
REDIS_URI: redis://redis:6379
@@ -197,6 +219,17 @@ services:
- redis
networks:
- taas-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-server.rule=Host(`api.telecom.com`) && PathPrefix(`/api/v1`)"
- "traefik.http.routers.api-server.entrypoints=websecure"
- "traefik.http.routers.api-server.tls=true"
- "traefik.http.routers.api-server.middlewares=api-ratelimit,secure-headers,compression"
- "traefik.http.services.api-server.loadbalancer.server.port=8000"
- "traefik.http.routers.api-server-docs.rule=Host(`api.telecom.com`) && Path(`/api/v1/swagger`) || Path(`/api/v1/swagger/*`)"
- "traefik.http.routers.api-server-docs.entrypoints=websecure"
- "traefik.http.routers.api-server-docs.tls=true"
- "traefik.http.routers.api-server-docs.middlewares=secure-headers,compression"
carrier-connector:
build:
@@ -204,11 +237,20 @@ services:
dockerfile: deployments/docker/carrier-connector.Dockerfile
container_name: taas-carrier-connector
restart: unless-stopped
expose:
- "8081"
environment:
SMDP_URL: https://smdp.example.com
SMDP_API_KEY: your-api-key
networks:
- taas-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.carrier-connector.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/es2`) || PathPrefix(`/v1/carrier`)"
- "traefik.http.routers.carrier-connector.entrypoints=websecure"
- "traefik.http.routers.carrier-connector.tls=true"
- "traefik.http.routers.carrier-connector.middlewares=carrier-ratelimit,secure-headers,compression,retry-attempts"
- "traefik.http.services.carrier-connector.loadbalancer.server.port=8081"
charging-engine:
build:
@@ -216,8 +258,8 @@ services:
dockerfile: deployments/docker/charging-engine.Dockerfile
container_name: taas-charging-engine
restart: unless-stopped
ports:
- "8080:8080"
expose:
- "8080"
environment:
REDIS_URI: redis://redis:6379
SERVER_PORT: 8080
@@ -226,6 +268,46 @@ services:
- redis
networks:
- taas-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.charging-engine.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/credit`) || PathPrefix(`/v1/usage`) || PathPrefix(`/v1/rating-plans`)"
- "traefik.http.routers.charging-engine.entrypoints=websecure"
- "traefik.http.routers.charging-engine.tls=true"
- "traefik.http.routers.charging-engine.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts"
- "traefik.http.services.charging-engine.loadbalancer.server.port=8080"
- "traefik.http.routers.charging-engine-health.rule=Host(`api.telecom.com`) && Path(`/v1/health`)"
- "traefik.http.routers.charging-engine-health.entrypoints=websecure"
- "traefik.http.routers.charging-engine-health.tls=true"
- "traefik.http.routers.charging-engine-health.middlewares=secure-headers"
packet-gateway:
build:
context: .
dockerfile: deployments/docker/packet-gateway.Dockerfile
container_name: taas-packet-gateway
restart: unless-stopped
expose:
- "8083"
environment:
REDIS_URL: redis://redis:6379
CHARGING_ENGINE_URL: http://charging-engine:8080
HEALTH_PORT: 8083
depends_on:
- redis
- charging-engine
networks:
- taas-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.packet-gateway.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/packet`) || PathPrefix(`/v1/gateway`)"
- "traefik.http.routers.packet-gateway.entrypoints=websecure"
- "traefik.http.routers.packet-gateway.tls=true"
- "traefik.http.routers.packet-gateway.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts"
- "traefik.http.services.packet-gateway.loadbalancer.server.port=8083"
- "traefik.http.routers.packet-gateway-health.rule=Host(`api.telecom.com`) && Path(`/health`)"
- "traefik.http.routers.packet-gateway-health.entrypoints=websecure"
- "traefik.http.routers.packet-gateway-health.tls=true"
- "traefik.http.routers.packet-gateway-health.middlewares=secure-headers"
web-dashboard:
build:
@@ -236,7 +318,7 @@ services:
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://api-server:8000
NEXT_PUBLIC_API_URL: https://api.telecom.com
depends_on:
- api-server
networks:
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# Telecom Platform API Gateway Startup Script
# This script starts the platform with Traefik as the API Gateway
set -e
echo "Starting Telecom Platform with API Gateway..."
# Check for required environment variables
if [ -z "$JWT_SECRET" ]; then
echo "WARNING: JWT_SECRET not set, using default for development only"
export JWT_SECRET="change-me-in-production-jwt-secret-32-chars-min"
fi
# Create necessary directories
mkdir -p traefik/dynamic
# Stop any existing containers
echo "Stopping existing containers..."
docker-compose down
# Build and start services
echo "Building and starting services with API Gateway..."
docker-compose up --build -d
# Wait for services to be ready
echo "Waiting for services to be ready..."
sleep 10
# Check service health
echo "Checking service health..."
# Check Traefik
if curl -f http://localhost:8080/ping >/dev/null 2>&1; then
echo "Traefik: OK"
else
echo "Traefik: FAILED"
fi
# Check API Server through gateway
if curl -f http://localhost/api/v1/health >/dev/null 2>&1; then
echo "API Server: OK"
else
echo "API Server: FAILED"
fi
# Check Charging Engine through gateway
if curl -f http://localhost/v1/health >/dev/null 2>&1; then
echo "Charging Engine: OK"
else
echo "Charging Engine: FAILED"
fi
echo ""
echo "API Gateway is running!"
echo ""
echo "Services:"
echo " - Traefik Dashboard: http://localhost:8080"
echo " - API Server: https://api.telecom.com/api/v1"
echo " - Charging Engine: https://api.telecom.com/v1/credit"
echo " - Carrier Connector: https://api.telecom.com/v1/es2"
echo " - Packet Gateway: https://api.telecom.com/v1/packet"
echo " - Web Dashboard: http://localhost:3000"
echo ""
echo "To add api.telecom.com to your hosts file:"
echo " echo '127.0.0.1 api.telecom.com' | sudo tee -a /etc/hosts"
echo ""
echo "To view logs:"
echo " docker-compose logs -f traefik"
echo " docker-compose logs -f api-server"
echo " docker-compose logs -f charging-engine"
+56
View File
@@ -0,0 +1,56 @@
http:
middlewares:
secure-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
customResponseHeaders:
X-Content-Type-Options: "nosniff"
X-Frame-Options: "DENY"
X-XSS-Protection: "1; mode=block"
Referrer-Policy: "strict-origin-when-cross-origin"
Permissions-Policy: "camera=(), microphone=(), geolocation=()"
Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
# Rate Limiting Middleware (API endpoints)
api-ratelimit:
rateLimit:
average: 100
period: "1m"
burst: 200
# Rate Limiting Middleware (Charging endpoints - more restrictive)
charging-ratelimit:
rateLimit:
average: 1000
period: "1m"
burst: 2000
# Rate Limiting Middleware (Carrier endpoints - very restrictive)
carrier-ratelimit:
rateLimit:
average: 50
period: "1m"
burst: 100
# JWT Authentication Middleware
jwt-auth:
plugin:
JWTAuth:
secret: "${JWT_SECRET}"
authHeaderName: "Authorization"
responseHeaderName: "X-Auth-User"
forwardAuth: true
# Compression Middleware
compression:
compress:
excludedContentTypes:
- "text/event-stream"
- "application/grpc"
# Retry Middleware
retry-attempts:
retry:
attempts: 3
initialInterval: "100ms"
+38
View File
@@ -0,0 +1,38 @@
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
http:
middlewares:
- secure-headers@file
providers:
docker:
exposedByDefault: false
network: taas-network
file:
directory: /traefik/dynamic
watch: true
log:
level: INFO
format: json
accessLog: {}
metrics:
prometheus:
addEntryPointsLabels: true
addServicesLabels: true
ping: {}