diff --git a/api/mobile.http b/api/mobile.http index c09b42c8..519e8483 100644 --- a/api/mobile.http +++ b/api/mobile.http @@ -29,6 +29,7 @@ Authorization: Bearer {{mobileToken}} Content-Type: application/json { + "id": "LGWvvI23l1DerKrwdr35t", "name": "Android Phone" } diff --git a/go.mod b/go.mod index 35231dc6..5918df6d 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( firebase.google.com/go/v4 v4.19.0 - github.com/android-sms-gateway/client-go v1.12.9-0.20260519005959-eae21b02f80f + github.com/android-sms-gateway/client-go v1.12.10-0.20260524050942-22cc236032cd github.com/ansrivas/fiberprometheus/v2 v2.6.1 github.com/capcom6/go-helpers v0.3.0 github.com/capcom6/go-infra-fx v0.5.2 @@ -14,7 +14,7 @@ require ( github.com/go-core-fx/logger v0.0.0-20251028014216-c34d2fb15ca2 github.com/go-playground/assert/v2 v2.2.0 github.com/go-playground/validator/v10 v10.28.0 - github.com/go-sql-driver/mysql v1.7.1 + github.com/go-sql-driver/mysql v1.8.1 github.com/gofiber/fiber/v2 v2.52.12 github.com/gofiber/swagger v1.1.1 github.com/golang-jwt/jwt/v5 v5.3.0 @@ -30,7 +30,8 @@ require ( golang.org/x/crypto v0.49.0 google.golang.org/api v0.273.0 gopkg.in/yaml.v3 v3.0.1 - gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde + gorm.io/datatypes v1.2.7 + gorm.io/gorm v1.30.0 ) require ( @@ -44,6 +45,7 @@ require ( cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/storage v1.61.3 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect @@ -134,7 +136,7 @@ require ( google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gorm.io/driver/mysql v1.5.2 // indirect + gorm.io/driver/mysql v1.5.6 // indirect gorm.io/driver/postgres v1.5.6 // indirect gorm.io/driver/sqlite v1.5.5 // indirect moul.io/zapgorm2 v1.3.0 // indirect diff --git a/go.sum b/go.sum index cdd79d08..6bd3c506 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KM cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= @@ -50,8 +52,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/android-sms-gateway/client-go v1.12.9-0.20260519005959-eae21b02f80f h1:NdX7nE17ObbkQcVyJPeAsZ9kvVtCbHu0hdB4/12UkqM= -github.com/android-sms-gateway/client-go v1.12.9-0.20260519005959-eae21b02f80f/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= +github.com/android-sms-gateway/client-go v1.12.10-0.20260524050942-22cc236032cd h1:SmQFhgS3gOcCjkVBaslbxxgISlyvPIN9RVpMTT3Mv/A= +github.com/android-sms-gateway/client-go v1.12.10-0.20260524050942-22cc236032cd/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM= @@ -148,8 +150,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= github.com/gofiber/contrib/fiberzap/v2 v2.1.6 h1:8aMBaO7jAB4w9o2uGC1S3ieKPxg8vfJ7t1aipq2pudg= @@ -165,6 +167,10 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -237,6 +243,8 @@ github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEj github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -461,16 +469,20 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= -gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= -gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU= gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= -gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg= -gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= diff --git a/internal/sms-gateway/handlers/converters/devices.go b/internal/sms-gateway/handlers/converters/devices.go index 5e1861ba..40ad9192 100644 --- a/internal/sms-gateway/handlers/converters/devices.go +++ b/internal/sms-gateway/handlers/converters/devices.go @@ -2,11 +2,12 @@ package converters import ( "github.com/android-sms-gateway/client-go/smsgateway" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/capcom6/go-helpers/anys" + "github.com/samber/lo" ) -func DeviceToDTO(device models.Device) smsgateway.Device { +func DeviceToDTO(device devices.Device) smsgateway.Device { return smsgateway.Device{ ID: device.ID, Name: anys.OrDefault(device.Name, ""), @@ -14,5 +15,22 @@ func DeviceToDTO(device models.Device) smsgateway.Device { UpdatedAt: device.UpdatedAt, DeletedAt: device.DeletedAt, LastSeen: device.LastSeen, + SimCards: mapSimCards(device.SimCards), } } + +func mapSimCards(simCards []devices.SimCard) []smsgateway.SimCard { + if simCards == nil { + return nil + } + + return lo.Map(simCards, func(sc devices.SimCard, _ int) smsgateway.SimCard { + return smsgateway.SimCard{ + SlotIndex: sc.SlotIndex, + SimNumber: sc.SimNumber, + PhoneNumber: sc.PhoneNumber, + CarrierName: sc.CarrierName, + ICCID: sc.ICCID, + } + }) +} diff --git a/internal/sms-gateway/handlers/converters/devices_test.go b/internal/sms-gateway/handlers/converters/devices_test.go index 06b78c08..ff2c005c 100644 --- a/internal/sms-gateway/handlers/converters/devices_test.go +++ b/internal/sms-gateway/handlers/converters/devices_test.go @@ -6,9 +6,9 @@ import ( "github.com/android-sms-gateway/client-go/smsgateway" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/converters" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" - "github.com/capcom6/go-helpers/anys" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/go-playground/assert/v2" + "github.com/samber/lo" ) func TestDeviceToDTO(t *testing.T) { @@ -18,26 +18,27 @@ func TestDeviceToDTO(t *testing.T) { tests := []struct { name string - device models.Device + device devices.Device expected smsgateway.Device }{ { name: "empty device", - device: models.Device{}, + device: devices.Device{}, expected: smsgateway.Device{}, }, { name: "non-empty device", - device: models.Device{ - ID: "test-id", - Name: anys.AsPointer("test-name"), - LastSeen: lastSeenAt, - SoftDeletableModel: models.SoftDeletableModel{ - TimedModel: models.TimedModel{ - CreatedAt: createdAt, - UpdatedAt: updatedAt, + device: devices.Device{ + DeviceInput: devices.DeviceInput{ + DeviceInfo: devices.DeviceInfo{ + DeviceUpdate: devices.DeviceUpdate{}, + Name: lo.ToPtr("test-name"), }, + ID: "test-id", }, + LastSeen: lastSeenAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, }, expected: smsgateway.Device{ ID: "test-id", @@ -49,15 +50,52 @@ func TestDeviceToDTO(t *testing.T) { }, { name: "device with nil name", - device: models.Device{ - ID: "test-id", - Name: nil, + device: devices.Device{ + DeviceInput: devices.DeviceInput{ + DeviceInfo: devices.DeviceInfo{ + Name: nil, + }, + ID: "test-id", + }, }, expected: smsgateway.Device{ ID: "test-id", Name: "", }, }, + { + name: "device with sim cards", + device: devices.Device{ + DeviceInput: devices.DeviceInput{ + DeviceInfo: devices.DeviceInfo{ + DeviceUpdate: devices.DeviceUpdate{ + SimCards: []devices.SimCard{ + { + SlotIndex: 0, + SimNumber: 1, + PhoneNumber: lo.ToPtr("+79990001234"), + CarrierName: lo.ToPtr("Carrier"), + ICCID: lo.ToPtr("8901260000000000000"), + }, + }, + }, + }, + ID: "test-id", + }, + }, + expected: smsgateway.Device{ + ID: "test-id", + SimCards: []smsgateway.SimCard{ + { + SlotIndex: 0, + SimNumber: 1, + PhoneNumber: lo.ToPtr("+79990001234"), + CarrierName: lo.ToPtr("Carrier"), + ICCID: lo.ToPtr("8901260000000000000"), + }, + }, + }, + }, } for _, test := range tests { diff --git a/internal/sms-gateway/handlers/devices/3rdparty.go b/internal/sms-gateway/handlers/devices/3rdparty.go index a4937c57..09d153f6 100644 --- a/internal/sms-gateway/handlers/devices/3rdparty.go +++ b/internal/sms-gateway/handlers/devices/3rdparty.go @@ -4,14 +4,15 @@ import ( "errors" "fmt" + "github.com/android-sms-gateway/client-go/smsgateway" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/converters" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/permissions" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/userauth" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" - "github.com/capcom6/go-helpers/slices" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + "github.com/samber/lo" "go.uber.org/zap" ) @@ -50,14 +51,17 @@ func NewThirdPartyController( // // List devices. func (h *ThirdPartyController) get(userID string, c *fiber.Ctx) error { - devices, err := h.devicesSvc.Select(userID) + items, err := h.devicesSvc.Select(c.Context(), userID) if err != nil { return fmt.Errorf("failed to select devices: %w", err) } - response := slices.Map(devices, converters.DeviceToDTO) - - return c.JSON(response) + return c.JSON(lo.Map( + items, + func(device devices.Device, _ int) smsgateway.Device { + return converters.DeviceToDTO(device) + }, + )) } // @Summary Remove device @@ -79,7 +83,7 @@ func (h *ThirdPartyController) get(userID string, c *fiber.Ctx) error { func (h *ThirdPartyController) remove(userID string, c *fiber.Ctx) error { id := c.Params("id") - err := h.devicesSvc.Remove(userID, devices.WithID(id)) + err := h.devicesSvc.Remove(c.Context(), userID, devices.WithID(id)) if errors.Is(err, devices.ErrNotFound) { return fiber.NewError(fiber.StatusNotFound, err.Error()) } diff --git a/internal/sms-gateway/handlers/events/mobile.go b/internal/sms-gateway/handlers/events/mobile.go index 8cbb6a04..c12769a7 100644 --- a/internal/sms-gateway/handlers/events/mobile.go +++ b/internal/sms-gateway/handlers/events/mobile.go @@ -3,7 +3,7 @@ package events import ( "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/deviceauth" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/sse" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -42,7 +42,7 @@ func NewMobileController(sseService *sse.Service, validator *validator.Validate, // @Router /mobile/v1/events [get] // // Get events. -func (h *MobileController) get(device models.Device, c *fiber.Ctx) error { +func (h *MobileController) get(device devices.Device, c *fiber.Ctx) error { return h.sseSvc.Handler(device.ID, c) //nolint:wrapcheck //wrapped internally } diff --git a/internal/sms-gateway/handlers/messages/3rdparty.go b/internal/sms-gateway/handlers/messages/3rdparty.go index 47890172..ef23ecab 100644 --- a/internal/sms-gateway/handlers/messages/3rdparty.go +++ b/internal/sms-gateway/handlers/messages/3rdparty.go @@ -91,6 +91,7 @@ func (h *ThirdPartyController) post(userID string, c *fiber.Ctx) error { } device, err := h.devicesSvc.GetAny( + c.Context(), userID, req.DeviceID, time.Duration(params.DeviceActiveWithin)*time.Hour, @@ -249,14 +250,14 @@ func (h *ThirdPartyController) get(userID string, c *fiber.Ctx) error { // // Deprecated: use /3rdparty/v1/inbox/refresh instead. func (h *ThirdPartyController) postInboxExport(userID string, c *fiber.Ctx) error { - req := new(smsgateway.MessagesExportRequest) + req := new(smsgateway.InboxRefreshRequest) if err := h.BodyParserValidator(c, req); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } if err := h.inboxSvc.Refresh( userID, - &req.DeviceID, + req.DeviceID, req.Since, req.Until, []smsgateway.IncomingMessageType{smsgateway.IncomingMessageTypeSMS}, diff --git a/internal/sms-gateway/handlers/messages/mobile.go b/internal/sms-gateway/handlers/messages/mobile.go index 99434770..5076c487 100644 --- a/internal/sms-gateway/handlers/messages/mobile.go +++ b/internal/sms-gateway/handlers/messages/mobile.go @@ -8,7 +8,7 @@ import ( "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/converters" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/deviceauth" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/messages" "github.com/capcom6/go-helpers/slices" "github.com/go-playground/validator/v10" @@ -55,7 +55,7 @@ func NewMobileController(params mobileControllerParams) *MobileController { // @Router /mobile/v1/message [get] // // Get messages for sending. -func (h *MobileController) list(device models.Device, c *fiber.Ctx) error { +func (h *MobileController) list(device devices.Device, c *fiber.Ctx) error { // Get and validate order parameter params := new(mobileGetQueryParams) if err := h.QueryParserValidator(c, params); err != nil { @@ -90,7 +90,7 @@ func (h *MobileController) list(device models.Device, c *fiber.Ctx) error { // @Router /mobile/v1/message [patch] // // Update message state. -func (h *MobileController) patch(device models.Device, c *fiber.Ctx) error { +func (h *MobileController) patch(device devices.Device, c *fiber.Ctx) error { req := smsgateway.MobilePatchMessageRequest{} if err := h.BodyParserValidator(c, &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) diff --git a/internal/sms-gateway/handlers/middlewares/deviceauth/deviceauth.go b/internal/sms-gateway/handlers/middlewares/deviceauth/deviceauth.go index 240f6811..ef523599 100644 --- a/internal/sms-gateway/handlers/middlewares/deviceauth/deviceauth.go +++ b/internal/sms-gateway/handlers/middlewares/deviceauth/deviceauth.go @@ -4,7 +4,6 @@ import ( "errors" "strings" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/auth" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/gofiber/fiber/v2" @@ -29,7 +28,7 @@ func New(authSvc *auth.Service) fiber.Handler { // Get the token token := auth[7:] - device, err := authSvc.AuthorizeDevice(token) + device, err := authSvc.AuthorizeDevice(c.Context(), token) if errors.Is(err, devices.ErrNotFound) { return c.Next() } @@ -37,7 +36,7 @@ func New(authSvc *auth.Service) fiber.Handler { return fiber.NewError(fiber.StatusUnauthorized, err.Error()) } - c.Locals(LocalsDevice, device) + c.Locals(LocalsDevice, *device) return c.Next() } @@ -52,10 +51,10 @@ func HasDevice(c *fiber.Ctx) bool { // GetDevice returns the device stored in the Locals under the key LocalsDevice. // If the Locals do not contain a device, it returns an empty device. -func GetDevice(c *fiber.Ctx) models.Device { - device, ok := c.Locals(LocalsDevice).(models.Device) +func GetDevice(c *fiber.Ctx) devices.Device { + device, ok := c.Locals(LocalsDevice).(devices.Device) if !ok { - return models.Device{} + return devices.Device{} } return device @@ -80,7 +79,7 @@ func DeviceRequired() fiber.Handler { // // It is a convenience function that wraps the call to GetDevice and calls the // handler with the device as the first argument. -func WithDevice(handler func(models.Device, *fiber.Ctx) error) fiber.Handler { +func WithDevice(handler func(devices.Device, *fiber.Ctx) error) fiber.Handler { return func(c *fiber.Ctx) error { return handler(GetDevice(c), c) } diff --git a/internal/sms-gateway/handlers/mobile.go b/internal/sms-gateway/handlers/mobile.go index 505cb825..b46ee06e 100644 --- a/internal/sms-gateway/handlers/mobile.go +++ b/internal/sms-gateway/handlers/mobile.go @@ -13,15 +13,14 @@ import ( "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/userauth" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/settings" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/webhooks" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/auth" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/users" - "github.com/capcom6/go-helpers/anys" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/keyauth" "github.com/jaevor/go-nanoid" + "github.com/samber/lo" "go.uber.org/zap" ) @@ -74,6 +73,57 @@ func newMobileHandler( } } +func (h *mobileHandler) Register(router fiber.Router) { + router = router.Group("/mobile/v1") + + router.Post("/device", + userauth.NewBasic(h.usersSvc), + userauth.NewCode(h.authSvc), + keyauth.New(keyauth.Config{ + Next: func(c *fiber.Ctx) bool { + // Skip server key authorization in the following cases: + // 1. Public mode is enabled - allowing open registration + // 2. User is already authenticated - allowing device registration for existing users + return h.authSvc.IsPublic() || userauth.HasUser(c) + }, + Validator: func(_ *fiber.Ctx, token string) (bool, error) { + err := h.authSvc.AuthorizeRegistration(token) + if err != nil { + return false, fmt.Errorf("authorization failed: %w", err) + } + + return true, nil + }, + }), + h.postDevice, + ) + + router.Get("/user/code", + userauth.NewBasic(h.usersSvc), + userauth.UserRequired(), + userauth.WithUserID(h.getUserCode), + ) + + router.Use( + deviceauth.New(h.authSvc), + ) + + router.Get("/device", deviceauth.WithDevice(h.getDevice)) + + router.Use(deviceauth.DeviceRequired()) + + router.Patch("/device", deviceauth.WithDevice(h.patchDevice)) + + // Should be under `userauth.NewBasic` protection instead of `deviceauth` + router.Patch("/user/password", deviceauth.WithDevice(h.changePassword)) + + h.messagesCtrl.Register(router.Group("/message")) + h.messagesCtrl.Register(router.Group("/messages")) + h.webhooksCtrl.Register(router.Group("/webhooks")) + h.settingsCtrl.Register(router.Group("/settings")) + h.eventsCtrl.Register(router.Group("/events")) +} + // @Summary Get device information // @Description Returns device information // @Tags Device @@ -83,14 +133,14 @@ func newMobileHandler( // @Router /mobile/v1/device [get] // // Get device information. -func (h *mobileHandler) getDevice(device models.Device, c *fiber.Ctx) error { +func (h *mobileHandler) getDevice(device devices.Device, c *fiber.Ctx) error { res := smsgateway.MobileDeviceResponse{ ExternalIP: c.IP(), Device: nil, } if !device.IsEmpty() { - res.Device = anys.AsPointer(converters.DeviceToDTO(device)) + res.Device = lo.ToPtr(converters.DeviceToDTO(device)) } return c.JSON(res) @@ -138,7 +188,17 @@ func (h *mobileHandler) postDevice(c *fiber.Ctx) error { } } - device, err := h.authSvc.RegisterDevice(userID, req.Name, req.PushToken) + device, err := h.authSvc.RegisterDevice( + c.Context(), + userID, + devices.DeviceInfo{ + DeviceUpdate: devices.DeviceUpdate{ + PushToken: req.PushToken, + SimCards: h.simCardsToDomain(req.SimCards), + }, + Name: req.Name, + }, + ) if err != nil { return fmt.Errorf("failed to register device: %w", err) } @@ -165,7 +225,7 @@ func (h *mobileHandler) postDevice(c *fiber.Ctx) error { // @Router /mobile/v1/device [patch] // // Update device. -func (h *mobileHandler) patchDevice(device models.Device, c *fiber.Ctx) error { +func (h *mobileHandler) patchDevice(device devices.Device, c *fiber.Ctx) error { req := new(smsgateway.MobileUpdateRequest) if err := h.BodyParserValidator(c, req); err != nil { @@ -176,9 +236,12 @@ func (h *mobileHandler) patchDevice(device models.Device, c *fiber.Ctx) error { return fiber.ErrForbidden } - if err := h.devicesSvc.UpdatePushToken(req.Id, req.PushToken); err != nil { - h.Logger.Error("failed to update device", zap.Error(err), zap.String("device_id", req.Id)) - return fiber.NewError(fiber.StatusInternalServerError, "failed to update device") + err := h.devicesSvc.Update(c.Context(), req.Id, devices.DeviceUpdate{ + PushToken: lo.EmptyableToPtr(req.PushToken), + SimCards: h.simCardsToDomain(req.SimCards), + }) + if err != nil { + return fmt.Errorf("failed to update device: %w", err) } return c.SendStatus(fiber.StatusNoContent) @@ -222,7 +285,7 @@ func (h *mobileHandler) getUserCode(userID string, c *fiber.Ctx) error { // @Router /mobile/v1/user/password [patch] // // Change password. -func (h *mobileHandler) changePassword(device models.Device, c *fiber.Ctx) error { +func (h *mobileHandler) changePassword(device devices.Device, c *fiber.Ctx) error { req := new(smsgateway.MobileChangePasswordRequest) if err := h.BodyParserValidator(c, req); err != nil { @@ -237,53 +300,14 @@ func (h *mobileHandler) changePassword(device models.Device, c *fiber.Ctx) error return c.SendStatus(fiber.StatusNoContent) } -func (h *mobileHandler) Register(router fiber.Router) { - router = router.Group("/mobile/v1") - - router.Post("/device", - userauth.NewBasic(h.usersSvc), - userauth.NewCode(h.authSvc), - keyauth.New(keyauth.Config{ - Next: func(c *fiber.Ctx) bool { - // Skip server key authorization in the following cases: - // 1. Public mode is enabled - allowing open registration - // 2. User is already authenticated - allowing device registration for existing users - return h.authSvc.IsPublic() || userauth.HasUser(c) - }, - Validator: func(_ *fiber.Ctx, token string) (bool, error) { - err := h.authSvc.AuthorizeRegistration(token) - if err != nil { - return false, fmt.Errorf("authorization failed: %w", err) - } - - return true, nil - }, - }), - h.postDevice, - ) - - router.Get("/user/code", - userauth.NewBasic(h.usersSvc), - userauth.UserRequired(), - userauth.WithUserID(h.getUserCode), - ) - - router.Use( - deviceauth.New(h.authSvc), - ) - - router.Get("/device", deviceauth.WithDevice(h.getDevice)) - - router.Use(deviceauth.DeviceRequired()) - - router.Patch("/device", deviceauth.WithDevice(h.patchDevice)) - - // Should be under `userauth.NewBasic` protection instead of `deviceauth` - router.Patch("/user/password", deviceauth.WithDevice(h.changePassword)) - - h.messagesCtrl.Register(router.Group("/message")) - h.messagesCtrl.Register(router.Group("/messages")) - h.webhooksCtrl.Register(router.Group("/webhooks")) - h.settingsCtrl.Register(router.Group("/settings")) - h.eventsCtrl.Register(router.Group("/events")) +func (h *mobileHandler) simCardsToDomain(simCards []smsgateway.SimCard) []devices.SimCard { + return lo.Map(simCards, func(sc smsgateway.SimCard, _ int) devices.SimCard { + return devices.SimCard{ + SlotIndex: sc.SlotIndex, + SimNumber: sc.SimNumber, + PhoneNumber: sc.PhoneNumber, + CarrierName: sc.CarrierName, + ICCID: sc.ICCID, + } + }) } diff --git a/internal/sms-gateway/handlers/settings/mobile.go b/internal/sms-gateway/handlers/settings/mobile.go index 38c4fb32..708f4a63 100644 --- a/internal/sms-gateway/handlers/settings/mobile.go +++ b/internal/sms-gateway/handlers/settings/mobile.go @@ -3,7 +3,6 @@ package settings import ( "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/deviceauth" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/settings" "github.com/go-playground/validator/v10" @@ -14,12 +13,10 @@ import ( type MobileController struct { base.Handler - devicesSvc *devices.Service settingsSvc *settings.Service } func NewMobileController( - devicesSvc *devices.Service, settingsSvc *settings.Service, logger *zap.Logger, validator *validator.Validate, @@ -29,7 +26,6 @@ func NewMobileController( Logger: logger, Validator: validator, }, - devicesSvc: devicesSvc, settingsSvc: settingsSvc, } } @@ -45,7 +41,7 @@ func NewMobileController( // @Router /mobile/v1/settings [get] // // Get settings. -func (h *MobileController) get(device models.Device, c *fiber.Ctx) error { +func (h *MobileController) get(device devices.Device, c *fiber.Ctx) error { settings, err := h.settingsSvc.GetSettings(device.UserID, false) if err != nil { h.Logger.Error( diff --git a/internal/sms-gateway/handlers/webhooks/3rdparty.go b/internal/sms-gateway/handlers/webhooks/3rdparty.go index 036e74fe..313f4a2c 100644 --- a/internal/sms-gateway/handlers/webhooks/3rdparty.go +++ b/internal/sms-gateway/handlers/webhooks/3rdparty.go @@ -84,7 +84,7 @@ func (h *ThirdPartyController) post(userID string, c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } - if err := h.webhooksSvc.Replace(userID, dto); err != nil { + if err := h.webhooksSvc.Replace(c.Context(), userID, dto); err != nil { if webhooks.IsValidationError(err) { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } diff --git a/internal/sms-gateway/handlers/webhooks/mobile.go b/internal/sms-gateway/handlers/webhooks/mobile.go index bfbaefd7..07e78aa5 100644 --- a/internal/sms-gateway/handlers/webhooks/mobile.go +++ b/internal/sms-gateway/handlers/webhooks/mobile.go @@ -5,7 +5,7 @@ import ( "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base" "github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/deviceauth" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/webhooks" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -43,7 +43,7 @@ func NewMobileController( // @Router /mobile/v1/webhooks [get] // // List webhooks. -func (h *MobileController) get(device models.Device, c *fiber.Ctx) error { +func (h *MobileController) get(device devices.Device, c *fiber.Ctx) error { items, err := h.webhooksSvc.Select(device.UserID, webhooks.WithDeviceID(device.ID, false)) if err != nil { return fmt.Errorf("failed to select webhooks: %w", err) diff --git a/internal/sms-gateway/models/migration.go b/internal/sms-gateway/models/migration.go index e4d08a58..c2b36f9f 100644 --- a/internal/sms-gateway/models/migration.go +++ b/internal/sms-gateway/models/migration.go @@ -2,17 +2,7 @@ package models import ( "embed" - "fmt" - - "gorm.io/gorm" ) //go:embed migrations var migrations embed.FS - -func Migrate(db *gorm.DB) error { - if err := db.AutoMigrate(new(Device)); err != nil { - return fmt.Errorf("models migration failed: %w", err) - } - return nil -} diff --git a/internal/sms-gateway/models/migrations/mysql/20260507035019_device_sim_cards.sql b/internal/sms-gateway/models/migrations/mysql/20260507035019_device_sim_cards.sql new file mode 100644 index 00000000..29faaa9f --- /dev/null +++ b/internal/sms-gateway/models/migrations/mysql/20260507035019_device_sim_cards.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE `devices` +ADD `sim_cards` json NOT NULL DEFAULT '[]'; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +ALTER TABLE `devices` DROP `sim_cards`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/sms-gateway/models/models.go b/internal/sms-gateway/models/models.go index b719c33c..bde3796e 100644 --- a/internal/sms-gateway/models/models.go +++ b/internal/sms-gateway/models/models.go @@ -14,32 +14,3 @@ type SoftDeletableModel struct { DeletedAt *time.Time `gorm:"<-:update"` } - -type Device struct { - SoftDeletableModel - - ID string `gorm:"primaryKey;type:char(21)"` - Name *string `gorm:"type:varchar(128)"` - AuthToken string `gorm:"not null;uniqueIndex;type:char(21)"` - PushToken *string `gorm:"type:varchar(256)"` - - LastSeen time.Time `gorm:"not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3);index:idx_devices_last_seen"` - - UserID string `gorm:"not null;type:varchar(32)"` -} - -func NewDevice(name, pushToken *string) *Device { - //nolint:exhaustruct // partial constructor - return &Device{ - Name: name, - PushToken: pushToken, - } -} - -func (d *Device) IsEmpty() bool { - if d == nil { - return true - } - - return d.ID == "" -} diff --git a/internal/sms-gateway/models/models_test.go b/internal/sms-gateway/models/models_test.go deleted file mode 100644 index 2ce82db0..00000000 --- a/internal/sms-gateway/models/models_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package models_test - -import ( - "testing" - - "github.com/android-sms-gateway/server/internal/sms-gateway/models" -) - -func TestDevice_IsEmpty(t *testing.T) { - tests := []struct { - name string - d *models.Device - want bool - }{ - { - name: "nil Device", - d: nil, - want: true, - }, - { - name: "empty ID", - d: &models.Device{ - ID: "", - }, - want: true, - }, - { - name: "non-empty ID", - d: &models.Device{ - ID: "some-id", - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.d.IsEmpty(); got != tt.want { - t.Errorf("IsEmpty() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/sms-gateway/models/module.go b/internal/sms-gateway/models/module.go index 62582b38..6566ec4c 100644 --- a/internal/sms-gateway/models/module.go +++ b/internal/sms-gateway/models/module.go @@ -6,6 +6,5 @@ import ( //nolint:gochecknoinits // framework-specific func init() { - db.RegisterMigration(Migrate) db.RegisterGoose(migrations) } diff --git a/internal/sms-gateway/modules/auth/service.go b/internal/sms-gateway/modules/auth/service.go index 09f9a7db..07a56245 100644 --- a/internal/sms-gateway/modules/auth/service.go +++ b/internal/sms-gateway/modules/auth/service.go @@ -6,7 +6,6 @@ import ( "fmt" "time" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/online" "github.com/android-sms-gateway/server/internal/sms-gateway/otp" @@ -61,13 +60,14 @@ func (s *Service) GenerateUserCode(ctx context.Context, userID string) (*otp.Cod return code, nil } -func (s *Service) RegisterDevice(userID string, name, pushToken *string) (*models.Device, error) { - device := models.NewDevice( - name, - pushToken, - ) +func (s *Service) RegisterDevice( + ctx context.Context, + userID string, + info devices.DeviceInfo, +) (*devices.Device, error) { + device, err := s.devicesSvc.Insert(ctx, userID, info) - if err := s.devicesSvc.Insert(userID, device); err != nil { + if err != nil { return device, fmt.Errorf("failed to create device: %w", err) } @@ -90,17 +90,18 @@ func (s *Service) AuthorizeRegistration(token string) error { return ErrAuthorizationFailed } -func (s *Service) AuthorizeDevice(token string) (models.Device, error) { - device, err := s.devicesSvc.GetByToken(token) +func (s *Service) AuthorizeDevice(ctx context.Context, token string) (*devices.Device, error) { + device, err := s.devicesSvc.GetByToken(ctx, token) if err != nil { return device, fmt.Errorf("%w: %w", ErrAuthorizationFailed, err) } + //nolint:gosec // background online-update goroutine go func(id string) { const timeout = 5 * time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) + subCtx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - s.onlineSvc.SetOnline(ctx, id) + s.onlineSvc.SetOnline(subCtx, id) }(device.ID) device.LastSeen = time.Now() diff --git a/internal/sms-gateway/modules/devices/cache.go b/internal/sms-gateway/modules/devices/cache.go index 2f33cee2..c90f830d 100644 --- a/internal/sms-gateway/modules/devices/cache.go +++ b/internal/sms-gateway/modules/devices/cache.go @@ -5,25 +5,24 @@ import ( "fmt" "time" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" cacheImpl "github.com/capcom6/go-helpers/cache" ) type cache struct { - byID *cacheImpl.Cache[*models.Device] - byToken *cacheImpl.Cache[*models.Device] + byID *cacheImpl.Cache[*Device] + byToken *cacheImpl.Cache[*Device] } func newCache() *cache { const ttl = 10 * time.Minute return &cache{ - byID: cacheImpl.New[*models.Device](cacheImpl.Config{TTL: ttl}), - byToken: cacheImpl.New[*models.Device](cacheImpl.Config{TTL: ttl}), + byID: cacheImpl.New[*Device](cacheImpl.Config{TTL: ttl}), + byToken: cacheImpl.New[*Device](cacheImpl.Config{TTL: ttl}), } } -func (c *cache) Set(device models.Device) error { +func (c *cache) Set(device Device) error { err := errors.Join(c.byID.Set(device.ID, &device), c.byToken.Set(device.AuthToken, &device)) if err != nil { return fmt.Errorf("failed to cache device: %w", err) @@ -32,19 +31,19 @@ func (c *cache) Set(device models.Device) error { return nil } -func (c *cache) GetByID(id string) (models.Device, error) { +func (c *cache) GetByID(id string) (Device, error) { device, err := c.byID.Get(id) if err != nil { - return models.Device{}, fmt.Errorf("failed to get device by ID: %w", err) + return Device{}, fmt.Errorf("failed to get device by ID: %w", err) } return *device, nil } -func (c *cache) GetByToken(token string) (models.Device, error) { +func (c *cache) GetByToken(token string) (Device, error) { device, err := c.byToken.Get(token) if err != nil { - return models.Device{}, fmt.Errorf("failed to get device by token: %w", err) + return Device{}, fmt.Errorf("failed to get device by token: %w", err) } return *device, nil diff --git a/internal/sms-gateway/modules/devices/domain.go b/internal/sms-gateway/modules/devices/domain.go new file mode 100644 index 00000000..b9c4de8e --- /dev/null +++ b/internal/sms-gateway/modules/devices/domain.go @@ -0,0 +1,44 @@ +package devices + +import "time" + +type DeviceInput struct { + DeviceInfo + + ID string + UserID string + + AuthToken string `json:"-"` +} + +type DeviceInfo struct { + DeviceUpdate + + Name *string +} + +type DeviceUpdate struct { + PushToken *string + SimCards []SimCard +} + +type Device struct { + DeviceInput + + LastSeen time.Time + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +func (d Device) IsEmpty() bool { + return d.ID == "" +} + +type SimCard struct { + SlotIndex int // Zero-based index of the physical SIM slot (0, 1, ...). + SimNumber int // One-based number used by the application. + PhoneNumber *string + CarrierName *string + ICCID *string +} diff --git a/internal/sms-gateway/modules/devices/models.go b/internal/sms-gateway/modules/devices/models.go new file mode 100644 index 00000000..5d720d7e --- /dev/null +++ b/internal/sms-gateway/modules/devices/models.go @@ -0,0 +1,106 @@ +package devices + +import ( + "fmt" + "time" + + "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/samber/lo" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type DeviceModel struct { + models.SoftDeletableModel + + ID string `gorm:"primaryKey;type:char(21)"` + Name *string `gorm:"type:varchar(128)"` + AuthToken string `gorm:"not null;uniqueIndex;type:char(21)"` + PushToken *string `gorm:"type:varchar(256)"` + + LastSeen time.Time `gorm:"not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3);index:idx_devices_last_seen"` + + UserID string `gorm:"not null;type:varchar(32)"` + + SimCards datatypes.JSONSlice[simCardModel] `gorm:"serializer:json;type:json"` +} + +func newDeviceModel(device DeviceInput) *DeviceModel { + now := time.Now() + return &DeviceModel{ + SoftDeletableModel: models.SoftDeletableModel{ + TimedModel: models.TimedModel{ + CreatedAt: now, + UpdatedAt: now, + }, + DeletedAt: nil, + }, + + ID: device.ID, + Name: device.Name, + AuthToken: device.AuthToken, + PushToken: device.PushToken, + LastSeen: now, + UserID: device.UserID, + SimCards: lo.Map( + device.SimCards, + func(simCard SimCard, _ int) simCardModel { return newSimCardModel(simCard) }, + ), + } +} + +func (*DeviceModel) TableName() string { + return "devices" +} + +func (m *DeviceModel) toDomain() *Device { + if m == nil { + return nil + } + + return &Device{ + DeviceInput: DeviceInput{ + DeviceInfo: DeviceInfo{ + DeviceUpdate: DeviceUpdate{ + PushToken: m.PushToken, + SimCards: lo.Map(m.SimCards, func(m simCardModel, _ int) SimCard { return m.toDomain() }), + }, + + Name: m.Name, + }, + + ID: m.ID, + UserID: m.UserID, + + AuthToken: m.AuthToken, + }, + + LastSeen: m.LastSeen, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + DeletedAt: m.DeletedAt, + } +} + +type simCardModel struct { + SlotIndex int `json:"slotIndex"` + SimNumber int `json:"simNumber"` + PhoneNumber *string `json:"phoneNumber,omitempty"` + CarrierName *string `json:"carrierName,omitempty"` + ICCID *string `json:"iccid,omitempty"` +} + +func newSimCardModel(simCard SimCard) simCardModel { + return simCardModel(simCard) +} + +func (m simCardModel) toDomain() SimCard { + return SimCard(m) +} + +func Migrate(db *gorm.DB) error { + if err := db.AutoMigrate(new(DeviceModel)); err != nil { + return fmt.Errorf("devices migration failed: %w", err) + } + return nil +} diff --git a/internal/sms-gateway/modules/devices/module.go b/internal/sms-gateway/modules/devices/module.go index 445d1ae9..a57101d7 100644 --- a/internal/sms-gateway/modules/devices/module.go +++ b/internal/sms-gateway/modules/devices/module.go @@ -1,6 +1,7 @@ package devices import ( + "github.com/capcom6/go-infra-fx/db" "github.com/go-core-fx/logger" "go.uber.org/fx" ) @@ -16,3 +17,8 @@ func Module() fx.Option { fx.Provide(NewService), ) } + +//nolint:gochecknoinits // framework-specific +func init() { + db.RegisterMigration(Migrate) +} diff --git a/internal/sms-gateway/modules/devices/repository.go b/internal/sms-gateway/modules/devices/repository.go index 070601b9..4fc9431c 100644 --- a/internal/sms-gateway/modules/devices/repository.go +++ b/internal/sms-gateway/modules/devices/repository.go @@ -6,7 +6,8 @@ import ( "fmt" "time" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/samber/lo" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -26,15 +27,18 @@ func NewRepository(db *gorm.DB) *Repository { } } -func (r *Repository) Select(filter ...SelectFilter) ([]models.Device, error) { +func (r *Repository) Select(ctx context.Context, filter ...SelectFilter) ([]Device, error) { if len(filter) == 0 { return nil, ErrInvalidFilter } f := newFilter(filter...) - devices := []models.Device{} + devices := []DeviceModel{} + if err := f.apply(r.db.WithContext(ctx)).Find(&devices).Error; err != nil { + return nil, fmt.Errorf("failed to select devices: %w", err) + } - return devices, f.apply(r.db).Find(&devices).Error + return lo.Map(devices, func(m DeviceModel, _ int) Device { return *m.toDomain() }), nil } // Exists checks if there exists a device with the given filters. @@ -42,8 +46,12 @@ func (r *Repository) Select(filter ...SelectFilter) ([]models.Device, error) { // If the device does not exist, it returns false and nil error. If there is an // error during the query, it returns false and the error. Otherwise, it returns // true and nil error. -func (r *Repository) Exists(filters ...SelectFilter) (bool, error) { - err := newFilter(filters...).apply(r.db).Take(new(models.Device)).Error +func (r *Repository) Exists(ctx context.Context, filters ...SelectFilter) (bool, error) { + if len(filters) == 0 { + return false, ErrInvalidFilter + } + + err := newFilter(filters...).apply(r.db.WithContext(ctx)).Take(new(DeviceModel)).Error if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } @@ -53,31 +61,59 @@ func (r *Repository) Exists(filters ...SelectFilter) (bool, error) { return true, nil } -func (r *Repository) Get(filter ...SelectFilter) (models.Device, error) { - devices, err := r.Select(filter...) +func (r *Repository) Get(ctx context.Context, filter ...SelectFilter) (*Device, error) { + devices, err := r.Select(ctx, filter...) if err != nil { - return models.Device{}, fmt.Errorf("failed to get device: %w", err) + return nil, fmt.Errorf("failed to get device: %w", err) } if len(devices) == 0 { - return models.Device{}, ErrNotFound + return nil, ErrNotFound } if len(devices) > 1 { - return models.Device{}, ErrMoreThanOne + return nil, ErrMoreThanOne } - return devices[0], nil + return &devices[0], nil } -func (r *Repository) Insert(device *models.Device) error { - return r.db.Create(device).Error +func (r *Repository) Insert(ctx context.Context, device DeviceInput) (*Device, error) { + model := newDeviceModel(device) + + if err := r.db.WithContext(ctx).Create(model).Error; err != nil { + return nil, fmt.Errorf("failed to insert device: %w", err) + } + + return model.toDomain(), nil } -func (r *Repository) UpdatePushToken(id string, token *string) error { - res := r.db.Model((*models.Device)(nil)).Where("id = ?", id).Update("push_token", token) - if res.Error != nil { - return fmt.Errorf("failed to update device: %w", res.Error) +func (r *Repository) Update(ctx context.Context, id string, device DeviceUpdate) error { + updates := map[string]any{} + + if device.PushToken != nil { + updates["push_token"] = device.PushToken + } + + if device.SimCards != nil { + updates["sim_cards"] = datatypes.NewJSONSlice(lo.Map( + device.SimCards, + func(simCard SimCard, _ int) simCardModel { return newSimCardModel(simCard) }, + )) + } + + if len(updates) == 0 { + return nil + } + + err := r.db. + WithContext(ctx). + Model((*DeviceModel)(nil)). + Where("id = ?", id). + Updates(updates). + Error + if err != nil { + return fmt.Errorf("failed to update device: %w", err) } return nil @@ -87,32 +123,34 @@ func (r *Repository) SetLastSeen(ctx context.Context, id string, lastSeen time.T if lastSeen.IsZero() { return nil // ignore zero timestamps } - res := r.db.WithContext(ctx). - Model((*models.Device)(nil)). + + err := r.db. + WithContext(ctx). + Model((*DeviceModel)(nil)). Where("id = ? AND last_seen < ?", id, lastSeen). - UpdateColumn("last_seen", lastSeen) - if res.Error != nil { - return res.Error + UpdateColumn("last_seen", lastSeen). + Error + if err != nil { + return fmt.Errorf("failed to set last seen: %w", err) } - // RowsAffected==0 => not found or stale timestamp; treat as no-op. return nil } -func (r *Repository) Remove(filter ...SelectFilter) error { +func (r *Repository) Remove(ctx context.Context, filter ...SelectFilter) error { if len(filter) == 0 { return ErrInvalidFilter } f := newFilter(filter...) - return f.apply(r.db).Delete(new(models.Device)).Error + return f.apply(r.db.WithContext(ctx)).Delete(new(DeviceModel)).Error } func (r *Repository) Cleanup(ctx context.Context, until time.Time) (int64, error) { res := r.db. WithContext(ctx). Where("last_seen < ?", until). - Delete(new(models.Device)) + Delete(new(DeviceModel)) return res.RowsAffected, res.Error } diff --git a/internal/sms-gateway/modules/devices/service.go b/internal/sms-gateway/modules/devices/service.go index 13aca073..eaac32cd 100644 --- a/internal/sms-gateway/modules/devices/service.go +++ b/internal/sms-gateway/modules/devices/service.go @@ -7,9 +7,7 @@ import ( "math/rand/v2" "time" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/db" - "github.com/samber/lo" "go.uber.org/zap" ) @@ -42,19 +40,22 @@ func NewService( } } -func (s *Service) Insert(userID string, device *models.Device) error { - device.ID = s.idGen() - device.AuthToken = s.idGen() - device.UserID = userID +func (s *Service) Insert(ctx context.Context, userID string, device DeviceInfo) (*Device, error) { + input := DeviceInput{ + DeviceInfo: device, + ID: s.idGen(), + UserID: userID, + AuthToken: s.idGen(), + } - return s.devices.Insert(device) + return s.devices.Insert(ctx, input) } // Select returns a list of devices for a specific user that match the provided filters. -func (s *Service) Select(userID string, filter ...SelectFilter) ([]models.Device, error) { +func (s *Service) Select(ctx context.Context, userID string, filter ...SelectFilter) ([]Device, error) { filter = append(filter, WithUserID(userID)) - return s.devices.Select(filter...) + return s.devices.Select(ctx, filter...) } // Exists checks if there exists a device that matches the provided filters. @@ -62,23 +63,23 @@ func (s *Service) Select(userID string, filter ...SelectFilter) ([]models.Device // If the device does not exist, it returns false and nil error. If there is an // error during the query, it returns false and the error. Otherwise, it returns // true and nil error. -func (s *Service) Exists(userID string, filter ...SelectFilter) (bool, error) { +func (s *Service) Exists(ctx context.Context, userID string, filter ...SelectFilter) (bool, error) { filter = append(filter, WithUserID(userID)) - return s.devices.Exists(filter...) + return s.devices.Exists(ctx, filter...) } // Get returns a single device based on the provided filters for a specific user. // It ensures that the filter includes the user's ID. If no device matches the // criteria, it returns ErrNotFound. If more than one device matches, it returns // ErrMoreThanOne. -func (s *Service) Get(userID string, filter ...SelectFilter) (models.Device, error) { +func (s *Service) Get(ctx context.Context, userID string, filter ...SelectFilter) (*Device, error) { filter = append(filter, WithUserID(userID)) - return s.devices.Get(filter...) + return s.devices.Get(ctx, filter...) } -func (s *Service) GetAny(userID string, deviceID string, duration time.Duration) (*models.Device, error) { +func (s *Service) GetAny(ctx context.Context, userID string, deviceID string, duration time.Duration) (*Device, error) { filter := []SelectFilter{ WithUserID(userID), } @@ -89,7 +90,7 @@ func (s *Service) GetAny(userID string, deviceID string, duration time.Duration) filter = append(filter, ActiveWithin(duration)) } - devices, err := s.devices.Select(filter...) + devices, err := s.devices.Select(ctx, filter...) if err != nil { return nil, err } @@ -111,34 +112,36 @@ func (s *Service) GetAny(userID string, deviceID string, duration time.Duration) // // This method is used to retrieve a device by its auth token. If the device // does not exist, it returns ErrNotFound. -func (s *Service) GetByToken(token string) (models.Device, error) { +func (s *Service) GetByToken(ctx context.Context, token string) (*Device, error) { device, err := s.cache.GetByToken(token) + if err == nil { + return &device, nil + } + + devicePtr, err := s.devices.Get(ctx, WithToken(token)) if err != nil { - device, err = s.devices.Get(WithToken(token)) - if err != nil { - return device, err - } + return nil, err + } - if setErr := s.cache.Set(device); setErr != nil { - s.logger.Error("failed to cache device", zap.String("device_id", device.ID), zap.Error(setErr)) - } + if setErr := s.cache.Set(*devicePtr); setErr != nil { + s.logger.Error("failed to cache device", zap.String("device_id", devicePtr.ID), zap.Error(setErr)) } - return device, nil + return devicePtr, nil } -func (s *Service) UpdatePushToken(id string, token string) error { - if err := s.cache.DeleteByID(id); err != nil { +func (s *Service) Update(ctx context.Context, id string, device DeviceUpdate) error { + if err := s.devices.Update(ctx, id, device); err != nil { + return err + } + + if cacheErr := s.cache.DeleteByID(id); cacheErr != nil { s.logger.Error("failed to invalidate cache", zap.String("device_id", id), - zap.Error(err), + zap.Error(cacheErr), ) } - if err := s.devices.UpdatePushToken(id, lo.EmptyableToPtr(token)); err != nil { - return err - } - return nil } @@ -166,10 +169,10 @@ func (s *Service) SetLastSeen(ctx context.Context, batch map[string]time.Time) e // Remove removes devices for a specific user that match the provided filters. // It ensures that the filter includes the user's ID. -func (s *Service) Remove(userID string, filter ...SelectFilter) error { +func (s *Service) Remove(ctx context.Context, userID string, filter ...SelectFilter) error { filter = append(filter, WithUserID(userID)) - devices, err := s.devices.Select(filter...) + devices, err := s.devices.Select(ctx, filter...) if err != nil { return err } @@ -186,7 +189,7 @@ func (s *Service) Remove(userID string, filter ...SelectFilter) error { } } - if rmErr := s.devices.Remove(filter...); rmErr != nil { + if rmErr := s.devices.Remove(ctx, filter...); rmErr != nil { return rmErr } diff --git a/internal/sms-gateway/modules/events/service.go b/internal/sms-gateway/modules/events/service.go index 8ee7b766..2d2f2418 100644 --- a/internal/sms-gateway/modules/events/service.go +++ b/internal/sms-gateway/modules/events/service.go @@ -105,19 +105,19 @@ func (s *Service) Run(ctx context.Context) error { s.logger.Error("failed to deserialize event wrapper", zap.Error(jsonErr)) continue } - s.processEvent(wrapper) + s.processEvent(ctx, wrapper) } } } -func (s *Service) processEvent(wrapper *eventWrapper) { +func (s *Service) processEvent(ctx context.Context, wrapper *eventWrapper) { // Load devices from database filters := []devices.SelectFilter{} if wrapper.DeviceID != nil { filters = append(filters, devices.WithID(*wrapper.DeviceID)) } - devices, err := s.deviceSvc.Select(wrapper.UserID, filters...) + devices, err := s.deviceSvc.Select(ctx, wrapper.UserID, filters...) if err != nil { s.logger.Error("failed to select devices", zap.String("user_id", wrapper.UserID), zap.Error(err)) return diff --git a/internal/sms-gateway/modules/messages/models.go b/internal/sms-gateway/modules/messages/models.go index 771ebc54..37f51738 100644 --- a/internal/sms-gateway/modules/messages/models.go +++ b/internal/sms-gateway/modules/messages/models.go @@ -7,6 +7,7 @@ import ( "github.com/android-sms-gateway/client-go/smsgateway" "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/samber/lo" "gorm.io/gorm" ) @@ -43,7 +44,7 @@ type messageModel struct { IsHashed bool `gorm:"not null;type:tinyint(1) unsigned;default:0"` IsEncrypted bool `gorm:"not null;type:tinyint(1) unsigned;default:0"` - Device models.Device `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"` + Device devices.DeviceModel `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"` Recipients []messageRecipientModel `gorm:"foreignKey:MessageID;constraint:OnDelete:CASCADE"` States []messageStateModel `gorm:"foreignKey:MessageID;constraint:OnDelete:CASCADE"` } diff --git a/internal/sms-gateway/modules/messages/service.go b/internal/sms-gateway/modules/messages/service.go index a6e3587a..d7007816 100644 --- a/internal/sms-gateway/modules/messages/service.go +++ b/internal/sms-gateway/modules/messages/service.go @@ -9,8 +9,8 @@ import ( "time" "github.com/android-sms-gateway/client-go/smsgateway" - "github.com/android-sms-gateway/server/internal/sms-gateway/models" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/db" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/modules/events" "github.com/capcom6/go-helpers/anys" "github.com/capcom6/go-helpers/slices" @@ -90,7 +90,7 @@ func (s *Service) SelectPending(deviceID string, order Order) ([]Message, error) return slices.MapOrError(messages, messageToDomain) //nolint:wrapcheck // already wrapped } -func (s *Service) UpdateState(device *models.Device, message MessageStateInput) error { +func (s *Service) UpdateState(device *devices.Device, message MessageStateInput) error { existing, err := s.messages.get( *new(SelectFilter).WithExtID(message.ID).WithDeviceID(device.ID), *new(SelectOptions).IncludeContent(), @@ -206,7 +206,7 @@ func (s *Service) GetState(userID string, id string) (*MessageState, error) { func (s *Service) Enqueue( ctx context.Context, - device models.Device, + device devices.Device, message MessageInput, opts EnqueueOptions, ) (*MessageState, error) { @@ -258,7 +258,7 @@ func (s *Service) Enqueue( } func (s *Service) prepareMessage( - device models.Device, + device devices.Device, message MessageInput, opts EnqueueOptions, ) (*messageModel, error) { diff --git a/internal/sms-gateway/modules/webhooks/models.go b/internal/sms-gateway/modules/webhooks/models.go index 15c7736c..13433db1 100644 --- a/internal/sms-gateway/modules/webhooks/models.go +++ b/internal/sms-gateway/modules/webhooks/models.go @@ -5,6 +5,7 @@ import ( "github.com/android-sms-gateway/client-go/smsgateway" "github.com/android-sms-gateway/server/internal/sms-gateway/models" + "github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices" "github.com/android-sms-gateway/server/internal/sms-gateway/users" "gorm.io/gorm" ) @@ -16,13 +17,13 @@ type Webhook struct { ExtID string `json:"id" gorm:"not null;type:varchar(36);uniqueIndex:unq_webhooks_user_extid,priority:2"` UserID string `json:"-" gorm:"<-:create;not null;type:varchar(32);uniqueIndex:unq_webhooks_user_extid,priority:1"` - DeviceID *string `json:"device_id,omitempty" gorm:"type:varchar(21);index:idx_webhooks_device"` + DeviceID *string `json:"device_id,omitempty" gorm:"type:char(21);index:idx_webhooks_device"` URL string `json:"url" validate:"required,http_url" gorm:"not null;type:varchar(256)"` Event smsgateway.WebhookEvent `json:"event" gorm:"not null;type:varchar(32)"` - User users.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` - Device *models.Device `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"` + User users.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + Device *devices.DeviceModel `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"` } func newWebhook(extID string, url string, event smsgateway.WebhookEvent, userID string, deviceID *string) *Webhook { diff --git a/internal/sms-gateway/modules/webhooks/service.go b/internal/sms-gateway/modules/webhooks/service.go index 28dc5b39..8ae7bc4a 100644 --- a/internal/sms-gateway/modules/webhooks/service.go +++ b/internal/sms-gateway/modules/webhooks/service.go @@ -1,6 +1,7 @@ package webhooks import ( + "context" "fmt" "github.com/android-sms-gateway/client-go/smsgateway" @@ -69,7 +70,7 @@ func (s *Service) Select(userID string, filters ...SelectFilter) ([]smsgateway.W // Replace creates or updates a webhook for a given user. After replacing the webhook, // it asynchronously notifies all the user's devices. Returns an error if the operation fails. -func (s *Service) Replace(userID string, webhook *smsgateway.Webhook) error { +func (s *Service) Replace(ctx context.Context, userID string, webhook *smsgateway.Webhook) error { if !smsgateway.IsValidWebhookEvent(webhook.Event) { return newValidationError("event", webhook.Event, ErrInvalidEvent) } @@ -80,7 +81,7 @@ func (s *Service) Replace(userID string, webhook *smsgateway.Webhook) error { // Check device ownership if deviceID is provided if webhook.DeviceID != nil { - ok, err := s.devicesSvc.Exists(userID, devices.WithID(*webhook.DeviceID)) + ok, err := s.devicesSvc.Exists(ctx, userID, devices.WithID(*webhook.DeviceID)) if err != nil { return fmt.Errorf("failed to verify device ownership: %w", err) } diff --git a/internal/sms-gateway/openapi/docs.go b/internal/sms-gateway/openapi/docs.go index 7beebb32..858214bd 100644 --- a/internal/sms-gateway/openapi/docs.go +++ b/internal/sms-gateway/openapi/docs.go @@ -1330,32 +1330,39 @@ const docTemplate = `{ "type": "object", "properties": { "createdAt": { - "description": "Created at (read only)", + "description": "Time at which the device was created, read only.", "type": "string", "example": "2020-01-01T00:00:00Z" }, "deletedAt": { - "description": "Deleted at (read only)", + "description": "Time at which the device was deleted, read only.", "type": "string", "example": "2020-01-01T00:00:00Z" }, "id": { - "description": "ID", + "description": "Device ID, read only.", "type": "string", "example": "PyDmBQZZXYmyxMwED8Fzy" }, "lastSeen": { - "description": "Last seen at (read only)", + "description": "Time at which the device was last seen, read only.", "type": "string", "example": "2020-01-01T00:00:00Z" }, "name": { - "description": "Name", + "description": "Device name.", "type": "string", "example": "My Device" }, + "simCards": { + "description": "List of SIM cards in the device.", + "type": "array", + "items": { + "$ref": "#/definitions/smsgateway.SimCard" + } + }, "updatedAt": { - "description": "Updated at (read only)", + "description": "Time at which the device was last updated, read only.", "type": "string", "example": "2020-01-01T00:00:00Z" } @@ -2229,6 +2236,31 @@ const docTemplate = `{ } } }, + "smsgateway.SimCard": { + "type": "object", + "properties": { + "carrierName": { + "description": "Carrier/network operator name (may be null).", + "type": "string" + }, + "iccid": { + "description": "Integrated Circuit Card Identifier (may be null).", + "type": "string" + }, + "phoneNumber": { + "description": "Phone number associated with the SIM.", + "type": "string" + }, + "simNumber": { + "description": "1-based slot number (1, 2, or 3).", + "type": "integer" + }, + "slotIndex": { + "description": "0-based physical slot index.", + "type": "integer" + } + } + }, "smsgateway.SimSelectionMode": { "type": "string", "enum": [