[settings] introduce settings module

This commit is contained in:
Aleksandr Soloshenko 2025-05-22 06:51:34 +07:00 committed by Aleksandr
parent dfe1341ec8
commit d8f9864e52
24 changed files with 1351 additions and 20 deletions

2
go.mod
View File

@ -6,7 +6,7 @@ toolchain go1.23.2
require (
firebase.google.com/go/v4 v4.12.1
github.com/android-sms-gateway/client-go v1.5.8
github.com/android-sms-gateway/client-go v1.6.0
github.com/ansrivas/fiberprometheus/v2 v2.6.1
github.com/capcom6/go-helpers v0.2.0
github.com/capcom6/go-infra-fx v0.2.1

14
go.sum
View File

@ -26,12 +26,14 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/android-sms-gateway/client-go v1.5.7 h1:1L9Ot3yc+5DtGaDOCUj4/8DEECWyfo4IoPyL+oXnzyE=
github.com/android-sms-gateway/client-go v1.5.7/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.5.8-0.20250516025314-5876d8deb355 h1:fctR5OH1c7g1zWEfp4K+fCZkY4+tZwTiKr/rN5N2yS8=
github.com/android-sms-gateway/client-go v1.5.8-0.20250516025314-5876d8deb355/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.5.8 h1:t9630c1Hv8u/MjwQ8epJ0iDpt3VXurSNFC91CFEjM/M=
github.com/android-sms-gateway/client-go v1.5.8/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.5.9-0.20250522134006-6e8b4dd3057a h1:TSmfm+KOsR1Ie10nZEjCVDepa1bEPin0NAgEUOSJiqw=
github.com/android-sms-gateway/client-go v1.5.9-0.20250522134006-6e8b4dd3057a/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.5.9-0.20250522231449-9e0855eff19f h1:VYrL6YbkQ49pcyiXTYcR5LN1WpNy1Tc684XjeE1UCvw=
github.com/android-sms-gateway/client-go v1.5.9-0.20250522231449-9e0855eff19f/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.5.9-0.20250524095300-2e41cae07049 h1:kdyVkqrgKDSI13JOKXVFz1al3IxfJPcbUaJvSXF6z+0=
github.com/android-sms-gateway/client-go v1.5.9-0.20250524095300-2e41cae07049/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.6.0 h1:3hN0XEUnNrweBl5Xx3IfE5zyq5ihm7fB0dhuTZBKlns=
github.com/android-sms-gateway/client-go v1.6.0/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM=

View File

@ -14,6 +14,7 @@ import (
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/messages"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/metrics"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/push"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/settings"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/webhooks"
"github.com/capcom6/go-infra-fx/cli"
"github.com/capcom6/go-infra-fx/db"
@ -40,6 +41,7 @@ var Module = fx.Module(
messages.Module,
health.Module,
webhooks.Module,
settings.Module,
devices.Module,
metrics.Module,
cleaner.Module,

View File

@ -6,6 +6,7 @@ import (
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/logs"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/messages"
"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/modules/auth"
"github.com/go-playground/validator/v10"
@ -21,6 +22,7 @@ type ThirdPartyHandlerParams struct {
MessagesHandler *messages.ThirdPartyController
WebhooksHandler *webhooks.ThirdPartyController
DevicesHandler *devices.ThirdPartyController
SettingsHandler *settings.ThirdPartyController
LogsHandler *logs.ThirdPartyController
AuthSvc *auth.Service
@ -36,6 +38,7 @@ type thirdPartyHandler struct {
messagesHandler *messages.ThirdPartyController
webhooksHandler *webhooks.ThirdPartyController
devicesHandler *devices.ThirdPartyController
settingsHandler *settings.ThirdPartyController
logsHandler *logs.ThirdPartyController
authSvc *auth.Service
@ -57,6 +60,8 @@ func (h *thirdPartyHandler) Register(router fiber.Router) {
h.devicesHandler.Register(router.Group("/device")) // TODO: remove after 2025-07-11
h.devicesHandler.Register(router.Group("/devices"))
h.settingsHandler.Register(router.Group("/settings"))
h.webhooksHandler.Register(router.Group("/webhooks"))
h.logsHandler.Register(router.Group("/logs"))
@ -69,6 +74,7 @@ func newThirdPartyHandler(params ThirdPartyHandlerParams) *thirdPartyHandler {
messagesHandler: params.MessagesHandler,
webhooksHandler: params.WebhooksHandler,
devicesHandler: params.DevicesHandler,
settingsHandler: params.SettingsHandler,
logsHandler: params.LogsHandler,
authSvc: params.AuthSvc,
}

View File

@ -32,9 +32,11 @@ func TestDeviceToDTO(t *testing.T) {
ID: "test-id",
Name: anys.AsPointer("test-name"),
LastSeen: lastSeenAt,
TimedModel: models.TimedModel{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
SoftDeletableModel: models.SoftDeletableModel{
TimedModel: models.TimedModel{
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
},
expected: smsgateway.Device{

View File

@ -10,6 +10,7 @@ import (
"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/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"
@ -33,6 +34,7 @@ type mobileHandler struct {
messagesSvc *messages.Service
webhooksCtrl *webhooks.MobileController
settingsCtrl *settings.MobileController
idGen func() string
}
@ -303,6 +305,8 @@ func (h *mobileHandler) Register(router fiber.Router) {
router.Patch("/user/password", deviceauth.WithDevice(h.changePassword))
h.webhooksCtrl.Register(router.Group("/webhooks"))
h.settingsCtrl.Register(router.Group("/settings"))
}
type mobileHandlerParams struct {
@ -316,6 +320,7 @@ type mobileHandlerParams struct {
MessagesSvc *messages.Service
WebhooksCtrl *webhooks.MobileController
SettingsCtrl *settings.MobileController
}
func newMobileHandler(params mobileHandlerParams) *mobileHandler {
@ -327,6 +332,7 @@ func newMobileHandler(params mobileHandlerParams) *mobileHandler {
devicesSvc: params.DevicesSvc,
messagesSvc: params.MessagesSvc,
webhooksCtrl: params.WebhooksCtrl,
settingsCtrl: params.SettingsCtrl,
idGen: idGen,
}
}

View File

@ -4,6 +4,7 @@ import (
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/devices"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/logs"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/messages"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/settings"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/webhooks"
"github.com/capcom6/go-infra-fx/http"
"go.uber.org/fx"
@ -27,6 +28,8 @@ var Module = fx.Module(
webhooks.NewThirdPartyController,
webhooks.NewMobileController,
devices.NewThirdPartyController,
settings.NewThirdPartyController,
settings.NewMobileController,
logs.NewThirdPartyController,
fx.Private,
),

View File

@ -0,0 +1,137 @@
package settings
import (
"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/middlewares/userauth"
"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"
"github.com/gofiber/fiber/v2"
"go.uber.org/fx"
"go.uber.org/zap"
)
type thirdPartyControllerParams struct {
fx.In
DevicesSvc *devices.Service
SettingsSvc *settings.Service
Validator *validator.Validate
Logger *zap.Logger
}
type ThirdPartyController struct {
base.Handler
devicesSvc *devices.Service
settingsSvc *settings.Service
}
// @Summary Get settings
// @Description Returns settings for a specific user
// @Security ApiAuth
// @Tags User, Settings
// @Produce json
// @Success 200 {object} smsgateway.DeviceSettings "Settings"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /3rdparty/v1/settings [get]
//
// Get settings
func (h *ThirdPartyController) get(user models.User, c *fiber.Ctx) error {
settings, err := h.settingsSvc.GetSettings(user.ID, true)
if err != nil {
return fmt.Errorf("can't get settings: %w", err)
}
return c.JSON(settings)
}
// @Summary Update settings
// @Description Updates settings for a specific user
// @Security ApiAuth
// @Tags User, Settings
// @Accept json
// @Produce json
// @Param request body smsgateway.DeviceSettings true "Settings"
// @Success 200 {object} object "Settings updated"
// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /3rdparty/v1/settings [put]
//
// Update settings
func (h *ThirdPartyController) put(user models.User, c *fiber.Ctx) error {
if err := h.BodyParserValidator(c, &smsgateway.DeviceSettings{}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid settings format: %v", err))
}
settings := make(map[string]any, 8)
if err := c.BodyParser(&settings); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to parse request body: %v", err))
}
updated, err := h.settingsSvc.ReplaceSettings(user.ID, settings)
if err != nil {
return fmt.Errorf("can't update settings: %w", err)
}
return c.JSON(updated)
}
// @Summary Partially update settings
// @Description Partially updates settings for a specific user
// @Security ApiAuth
// @Tags User, Settings
// @Accept json
// @Produce json
// @Param request body smsgateway.DeviceSettings true "Settings"
// @Success 200 {object} object "Settings updated"
// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /3rdparty/v1/settings [patch]
//
// Partially update settings
func (h *ThirdPartyController) patch(user models.User, c *fiber.Ctx) error {
if err := h.BodyParserValidator(c, &smsgateway.DeviceSettings{}); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid settings format: %v", err))
}
settings := make(map[string]any, 8)
if err := c.BodyParser(&settings); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to parse request body: %v", err))
}
updated, err := h.settingsSvc.UpdateSettings(user.ID, settings)
if err != nil {
return fmt.Errorf("can't update settings: %w", err)
}
return c.JSON(updated)
}
func (h *ThirdPartyController) Register(app fiber.Router) {
app.Get("", userauth.WithUser(h.get))
app.Patch("", userauth.WithUser(h.patch))
app.Put("", userauth.WithUser(h.put))
}
func NewThirdPartyController(params thirdPartyControllerParams) *ThirdPartyController {
return &ThirdPartyController{
Handler: base.Handler{
Logger: params.Logger.Named("settings"),
Validator: params.Validator,
},
devicesSvc: params.DevicesSvc,
settingsSvc: params.SettingsSvc,
}
}

View File

@ -0,0 +1,64 @@
package settings
import (
"fmt"
"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/gofiber/fiber/v2"
"go.uber.org/fx"
"go.uber.org/zap"
)
type mobileControllerParams struct {
fx.In
DevicesSvc *devices.Service
SettingsSvc *settings.Service
Logger *zap.Logger
}
type MobileController struct {
base.Handler
devicesSvc *devices.Service
settingsSvc *settings.Service
}
// @Summary Get settings
// @Description Returns settings for a device
// @Security MobileToken
// @Tags Device, Settings
// @Produce json
// @Success 200 {object} smsgateway.DeviceSettings "Settings"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /mobile/v1/settings [get]
//
// Get settings
func (h *MobileController) get(device models.Device, c *fiber.Ctx) error {
settings, err := h.settingsSvc.GetSettings(device.UserID, false)
if err != nil {
return fmt.Errorf("can't get settings for device %s (user ID: %s): %w", device.ID, device.UserID, err)
}
return c.JSON(settings)
}
func (h *MobileController) Register(router fiber.Router) {
router.Get("", deviceauth.WithDevice(h.get))
}
func NewMobileController(params mobileControllerParams) *MobileController {
return &MobileController{
Handler: base.Handler{
Logger: params.Logger.Named("settings"),
},
devicesSvc: params.DevicesSvc,
settingsSvc: params.SettingsSvc,
}
}

View File

@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE `device_settings` (
`user_id` varchar(32) NOT NULL,
`settings` json NOT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`user_id`),
CONSTRAINT `fk_device_settings_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
-- +goose StatementEnd
---
-- +goose Down
-- +goose StatementBegin
DROP TABLE `device_settings`;
-- +goose StatementEnd

View File

@ -15,8 +15,12 @@ const (
)
type TimedModel struct {
CreatedAt time.Time `gorm:"->;not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3)"`
UpdatedAt time.Time `gorm:"->;not null;autoupdatetime:false;default:CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"`
CreatedAt time.Time `gorm:"->;not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3)"`
UpdatedAt time.Time `gorm:"->;not null;autoupdatetime:false;default:CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"`
}
type SoftDeletableModel struct {
TimedModel
DeletedAt *time.Time `gorm:"<-:update"`
}
@ -25,7 +29,7 @@ type User struct {
PasswordHash string `gorm:"not null;type:varchar(72)"`
Devices []Device `gorm:"-,foreignKey:UserID;constraint:OnDelete:CASCADE"`
TimedModel
SoftDeletableModel
}
type Device struct {
@ -38,7 +42,7 @@ type Device struct {
UserID string `gorm:"not null;type:varchar(32)"`
TimedModel
SoftDeletableModel
}
func (d *Device) IsEmpty() bool {
@ -67,7 +71,7 @@ type Message struct {
Recipients []MessageRecipient `gorm:"foreignKey:MessageID;constraint:OnDelete:CASCADE"`
States []MessageState `gorm:"foreignKey:MessageID;constraint:OnDelete:CASCADE"`
TimedModel
SoftDeletableModel
}
type MessageRecipient struct {

View File

@ -168,6 +168,7 @@ func (s *Service) Notify(userID string, deviceID *string, event *domain.Event) e
}
errs := make([]error, 0, len(devices))
notifiedCount := 0
for _, device := range devices {
if device.PushToken == nil {
s.logger.Info("Device has no push token", zap.String("user_id", userID), zap.String("device_id", device.ID))
@ -177,10 +178,12 @@ func (s *Service) Notify(userID string, deviceID *string, event *domain.Event) e
if err := s.Enqueue(*device.PushToken, event); err != nil {
s.logger.Error("Failed to send push notification", zap.String("user_id", userID), zap.String("device_id", device.ID), zap.Error(err))
errs = append(errs, err)
} else {
notifiedCount++
}
}
s.logger.Info("Notified devices", append(logFields, zap.Int("count", len(devices)))...)
s.logger.Info("Notified devices", append(logFields, zap.Int("count", notifiedCount), zap.Int("total", len(devices)))...)
return errors.Join(errs...)
}

View File

@ -47,3 +47,7 @@ func NewMessagesExportRequestedEvent(since, until time.Time) *domain.Event {
},
)
}
func NewSettingsUpdatedEvent() *domain.Event {
return domain.NewEvent(smsgateway.PushSettingsUpdated, nil)
}

View File

@ -0,0 +1,24 @@
package settings
import (
"fmt"
"github.com/android-sms-gateway/server/internal/sms-gateway/models"
"gorm.io/gorm"
)
type DeviceSettings struct {
UserID string `gorm:"primaryKey;not null;type:varchar(32)"`
Settings map[string]any `gorm:"not null;type:json;serializer:json"`
User models.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
models.TimedModel
}
func Migrate(db *gorm.DB) error {
if err := db.AutoMigrate(&DeviceSettings{}); err != nil {
return fmt.Errorf("device_settings migration failed: %w", err)
}
return nil
}

View File

@ -0,0 +1,25 @@
package settings
import (
"github.com/capcom6/go-infra-fx/db"
"go.uber.org/fx"
"go.uber.org/zap"
)
var Module = fx.Module(
"settings",
fx.Decorate(func(log *zap.Logger) *zap.Logger {
return log.Named("settings")
}),
fx.Provide(
newRepository,
fx.Private,
),
fx.Provide(
NewService,
),
)
func init() {
db.RegisterMigration(Migrate)
}

View File

@ -0,0 +1,67 @@
package settings
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type repository struct {
db *gorm.DB
}
// GetSettings retrieves the device settings for a user by their userID.
func (r *repository) GetSettings(userID string) (*DeviceSettings, error) {
settings := &DeviceSettings{}
err := r.db.Where("user_id = ?", userID).Limit(1).Find(settings).Error
if err != nil {
return nil, err
}
return settings, nil
}
// UpdateSettings updates the settings for a user.
func (r *repository) UpdateSettings(settings *DeviceSettings) (*DeviceSettings, error) {
var updatedSettings *DeviceSettings
err := r.db.Transaction(func(tx *gorm.DB) error {
source := &DeviceSettings{UserID: settings.UserID}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Limit(1).Find(source).Error; err != nil {
return err
}
if source.Settings == nil {
source.Settings = map[string]any{}
}
var err error
settings.Settings, err = appendMap(source.Settings, settings.Settings, rules)
if err != nil {
return err
}
if err := tx.Clauses(clause.OnConflict{UpdateAll: true}).Create(settings).Error; err != nil {
return err
}
// Return the updated settings
updatedSettings = settings
return nil
})
return updatedSettings, err
}
// ReplaceSettings replaces the settings for a user.
//
// This function will overwrite all existing settings for the user.
func (r *repository) ReplaceSettings(settings *DeviceSettings) (*DeviceSettings, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
return tx.Save(settings).Error
})
return settings, err
}
func newRepository(db *gorm.DB) *repository {
return &repository{
db: db,
}
}

View File

@ -0,0 +1,93 @@
package settings
import (
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/push"
"go.uber.org/fx"
"go.uber.org/zap"
)
type ServiceParams struct {
fx.In
Repository *repository
Logger *zap.Logger
PushSvc *push.Service
}
type Service struct {
settings *repository
logger *zap.Logger
pushSvc *push.Service
}
func (s *Service) GetSettings(userID string, public bool) (map[string]any, error) {
settings, err := s.settings.GetSettings(userID)
if err != nil {
return nil, err
}
if !public {
return settings.Settings, nil
}
return filterMap(settings.Settings, rulesPublic)
}
func (s *Service) UpdateSettings(userID string, settings map[string]any) (map[string]any, error) {
filtered, err := filterMap(settings, rules)
if err != nil {
return nil, err
}
updatedSettings, err := s.settings.UpdateSettings(&DeviceSettings{
UserID: userID,
Settings: filtered,
})
if err != nil {
return nil, err
}
s.notifyDevices(userID)
return filterMap(updatedSettings.Settings, rulesPublic)
}
func (s *Service) ReplaceSettings(userID string, settings map[string]any) (map[string]any, error) {
filtered, err := filterMap(settings, rules)
if err != nil {
return nil, err
}
updated, err := s.settings.ReplaceSettings(&DeviceSettings{
UserID: userID,
Settings: filtered,
})
if err != nil {
return nil, err
}
s.notifyDevices(userID)
return filterMap(updated.Settings, rulesPublic)
}
// notifyDevices asynchronously notifies all the user's devices.
func (s *Service) notifyDevices(userID string) {
go func(userID string) {
if err := s.pushSvc.Notify(userID, nil, push.NewSettingsUpdatedEvent()); err != nil {
s.logger.Error("can't notify devices", zap.Error(err))
}
}(userID)
}
func NewService(params ServiceParams) *Service {
return &Service{
settings: params.Repository,
logger: params.Logger.Named("service"),
pushSvc: params.PushSvc,
}
}

View File

@ -0,0 +1,112 @@
package settings
import "fmt"
var rules = map[string]any{
"encryption": map[string]any{
"passphrase": "",
},
"messages": map[string]any{
"send_interval_min": "",
"send_interval_max": "",
"limit_period": "",
"limit_value": "",
"sim_selection_mode": "",
"log_lifetime_days": "",
},
"ping": map[string]any{
"interval_seconds": "",
},
"logs": map[string]any{
"lifetime_days": "",
},
"webhooks": map[string]any{
"internet_required": "",
"retry_count": "",
"signing_key": "",
},
}
var rulesPublic = map[string]any{
"encryption": map[string]any{},
"messages": map[string]any{
"send_interval_min": "",
"send_interval_max": "",
"limit_period": "",
"limit_value": "",
"sim_selection_mode": "",
"log_lifetime_days": "",
},
"ping": map[string]any{
"interval_seconds": "",
},
"logs": map[string]any{
"lifetime_days": "",
},
"webhooks": map[string]any{
"internet_required": "",
"retry_count": "",
},
}
func filterMap(m map[string]any, r map[string]any) (map[string]any, error) {
var err error
result := make(map[string]any)
for field, rule := range r {
if ruleObj, ok := rule.(map[string]any); ok {
if dataObj, ok := m[field].(map[string]any); ok {
result[field], err = filterMap(dataObj, ruleObj)
if err != nil {
return nil, err
}
} else if m[field] == nil {
continue
} else {
return nil, fmt.Errorf("the field: '%s' is not a map to dive", field)
}
} else if _, ok := rule.(string); ok {
if _, ok := m[field]; !ok {
continue
}
result[field] = m[field]
}
}
return result, nil
}
func appendMap(m1, m2 map[string]any, rules map[string]any) (map[string]any, error) {
var err error
for field, rule := range rules {
if ruleObj, ok := rule.(map[string]any); ok {
if dataObj, ok := m2[field].(map[string]any); ok {
if m1Field, ok := m1[field].(map[string]any); ok {
m1[field], err = appendMap(m1Field, dataObj, ruleObj)
if err != nil {
return nil, err
}
} else {
// Initialize if not present or not a map
newMap := make(map[string]any)
m1[field], err = appendMap(newMap, dataObj, ruleObj)
if err != nil {
return nil, err
}
}
} else if m2[field] == nil {
continue
} else {
return nil, fmt.Errorf("expected field '%s' to be a map, but got %T", field, m2[field])
}
} else if _, ok := rule.(string); ok {
if _, ok := m2[field]; !ok {
continue
}
m1[field] = m2[field]
}
}
return m1, nil
}

View File

@ -19,7 +19,7 @@ type Webhook struct {
User models.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
Device *models.Device `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"`
models.TimedModel
models.SoftDeletableModel
}
func Migrate(db *gorm.DB) error {

View File

@ -80,3 +80,56 @@ Authorization: Basic {{localCredentials}}
###
GET {{localUrl}}/logs?from=2025-02-05T20:39:46.190%2B07:00 HTTP/1.1
Authorization: Basic {{localCredentials}}
###
GET {{localUrl}}/settings HTTP/1.1
Authorization: Basic {{localCredentials}}
###
PATCH {{localUrl}}/settings HTTP/1.1
Authorization: Basic {{localCredentials}}
Content-Type: application/json
{
"encryption": {
"passphrase": null
},
"gateway": {
"cloud_url": "https://api.sms-gate.app/mobile/v1",
"private_token": null
},
"messages": {
"send_interval_min": null,
"send_interval_max": null,
"limit_period": "Disabled",
"limit_value": null,
"sim_selection_mode": "OSDefault",
"log_lifetime_days": null
},
"localserver": {
"PORT": 8080
},
"ping": {
"interval_seconds": null
},
"logs": {
"lifetime_days": 30
},
"webhooks": {
"internet_required": true,
"retry_count": 1,
"signing_key": null
}
}
###
PATCH {{localUrl}}/settings HTTP/1.1
Authorization: Basic {{localCredentials}}
Content-Type: application/json
{
"webhooks": {
"internet_required": true,
"retry_count": 1
}
}

View File

@ -71,3 +71,7 @@ Content-Type: application/json
"currentPassword": "wsmgz1akhoo24o",
"newPassword": "wsmgz1akhoo24o"
}
###
GET {{baseUrl}}/settings HTTP/1.1
Authorization: Bearer {{mobileToken}}

View File

@ -103,6 +103,52 @@ Authorization: Basic {{credentials}}
GET {{baseUrl}}/api/3rdparty/v1/logs HTTP/1.1
Authorization: Basic {{credentials}}
###
GET {{baseUrl}}/3rdparty/v1/settings HTTP/1.1
Authorization: Basic {{credentials}}
###
PATCH {{baseUrl}}/3rdparty/v1/settings HTTP/1.1
Authorization: Basic {{credentials}}
Content-Type: application/json
{
"messages": {
"send_interval_min": null,
"send_interval_max": 1
}
}
###
PUT {{baseUrl}}/3rdparty/v1/settings HTTP/1.1
Authorization: Basic {{credentials}}
Content-Type: application/json
{
"encryption": {
"passphrase": "{{$guid}}"
},
"messages": {
"send_interval_min": null,
"send_interval_max": null,
"limit_period": "Disabled",
"limit_value": null,
"sim_selection_mode": "OSDefault",
"log_lifetime_days": null
},
"ping": {
"interval_seconds": null
},
"logs": {
"lifetime_days": 30
},
"webhooks": {
"internet_required": true,
"retry_count": 1,
"signing_key": "{{$guid}}"
}
}
###
GET http://localhost:3000/metrics HTTP/1.1

View File

@ -393,6 +393,156 @@
}
}
},
"/3rdparty/v1/settings": {
"get": {
"security": [
{
"ApiAuth": []
}
],
"description": "Returns settings for a specific user",
"produces": [
"application/json"
],
"tags": [
"User",
"Settings"
],
"summary": "Get settings",
"responses": {
"200": {
"description": "Settings",
"schema": {
"$ref": "#/definitions/smsgateway.DeviceSettings"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
},
"put": {
"security": [
{
"ApiAuth": []
}
],
"description": "Updates settings for a specific user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User",
"Settings"
],
"summary": "Update settings",
"parameters": [
{
"description": "Settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/smsgateway.DeviceSettings"
}
}
],
"responses": {
"200": {
"description": "Settings updated",
"schema": {
"type": "object"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
},
"patch": {
"security": [
{
"ApiAuth": []
}
],
"description": "Partially updates settings for a specific user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User",
"Settings"
],
"summary": "Partially update settings",
"parameters": [
{
"description": "Settings",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/smsgateway.DeviceSettings"
}
}
],
"responses": {
"200": {
"description": "Settings updated",
"schema": {
"type": "object"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
}
},
"/3rdparty/v1/webhooks": {
"get": {
"security": [
@ -767,6 +917,44 @@
}
}
},
"/mobile/v1/settings": {
"get": {
"security": [
{
"MobileToken": []
}
],
"description": "Returns settings for a device",
"produces": [
"application/json"
],
"tags": [
"Device",
"Settings"
],
"summary": "Get settings",
"responses": {
"200": {
"description": "Settings",
"schema": {
"$ref": "#/definitions/smsgateway.DeviceSettings"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
}
},
"/mobile/v1/user/code": {
"get": {
"security": [
@ -985,6 +1173,59 @@
}
}
},
"smsgateway.DeviceSettings": {
"type": "object",
"properties": {
"encryption": {
"description": "Encryption contains settings related to message encryption.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsEncryption"
}
]
},
"gateway": {
"description": "Gateway contains settings related to the Cloud/Private server configuration.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsGateway"
}
]
},
"logs": {
"description": "Logs contains settings related to logging.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsLogs"
}
]
},
"messages": {
"description": "Messages contains settings related to message handling.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsMessages"
}
]
},
"ping": {
"description": "Ping contains settings related to ping functionality.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsPing"
}
]
},
"webhooks": {
"description": "Webhooks contains settings related to webhook functionality.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.SettingsWebhooks"
}
]
}
}
},
"smsgateway.ErrorResponse": {
"type": "object",
"properties": {
@ -1075,6 +1316,21 @@
"HealthStatusFail"
]
},
"smsgateway.LimitPeriod": {
"type": "string",
"enum": [
"Disabled",
"PerMinute",
"PerHour",
"PerDay"
],
"x-enum-varnames": [
"Disabled",
"PerMinute",
"PerHour",
"PerDay"
]
},
"smsgateway.LogEntry": {
"type": "object",
"properties": {
@ -1507,12 +1763,14 @@
"enum": [
"MessageEnqueued",
"WebhooksUpdated",
"MessagesExportRequested"
"MessagesExportRequested",
"SettingsUpdated"
],
"x-enum-varnames": [
"PushMessageEnqueued",
"PushWebhooksUpdated",
"PushMessagesExportRequested"
"PushMessagesExportRequested",
"PushSettingsUpdated"
]
},
"smsgateway.PushNotification": {
@ -1534,7 +1792,8 @@
"enum": [
"MessageEnqueued",
"WebhooksUpdated",
"MessagesExportRequested"
"MessagesExportRequested",
"SettingsUpdated"
],
"allOf": [
{
@ -1580,6 +1839,131 @@
}
}
},
"smsgateway.SettingsEncryption": {
"type": "object",
"properties": {
"passphrase": {
"description": "Passphrase is the encryption passphrase. If nil or empty, encryption is disabled.",
"type": "string"
}
}
},
"smsgateway.SettingsGateway": {
"type": "object",
"properties": {
"cloud_url": {
"description": "CloudURL is the URL of the server. If nil, the Cloud Public server is used.\nMust be a valid HTTPs URL when provided.",
"type": "string"
},
"private_token": {
"description": "PrivateToken is the private token for authenticating with the Private Server.",
"type": "string"
}
}
},
"smsgateway.SettingsLogs": {
"type": "object",
"properties": {
"lifetime_days": {
"description": "LifetimeDays is the number of days to retain logs.\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
}
}
},
"smsgateway.SettingsMessages": {
"type": "object",
"properties": {
"limit_period": {
"description": "LimitPeriod defines the period for message sending limits.\nValid values are \"Disabled\", \"PerMinute\", \"PerHour\", or \"PerDay\".",
"enum": [
"Disabled",
"PerMinute",
"PerHour",
"PerDay"
],
"allOf": [
{
"$ref": "#/definitions/smsgateway.LimitPeriod"
}
]
},
"limit_value": {
"description": "LimitValue is the maximum number of messages allowed per limit period.\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
},
"log_lifetime_days": {
"description": "LogLifetimeDays is the number of days to retain message logs.\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
},
"send_interval_max": {
"description": "SendIntervalMax is the maximum interval between message sends (in seconds).\nMust be at least 1 when provided and greater than or equal to SendIntervalMin.",
"type": "integer",
"minimum": 1
},
"send_interval_min": {
"description": "SendIntervalMin is the minimum interval between message sends (in seconds).\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
},
"sim_selection_mode": {
"description": "SimSelectionMode defines how SIM cards are selected for sending messages.\nValid values are \"OSDefault\", \"RoundRobin\", or \"Random\".",
"enum": [
"OSDefault",
"RoundRobin",
"Random"
],
"allOf": [
{
"$ref": "#/definitions/smsgateway.SimSelectionMode"
}
]
}
}
},
"smsgateway.SettingsPing": {
"type": "object",
"properties": {
"interval_seconds": {
"description": "IntervalSeconds is the interval between ping requests (in seconds).\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
}
}
},
"smsgateway.SettingsWebhooks": {
"type": "object",
"properties": {
"internet_required": {
"description": "InternetRequired indicates whether internet access is required for webhooks.",
"type": "boolean"
},
"retry_count": {
"description": "RetryCount is the number of times to retry failed webhook deliveries.\nMust be at least 1 when provided.",
"type": "integer",
"minimum": 1
},
"signing_key": {
"description": "SigningKey is the secret key used for signing webhook payloads.",
"type": "string"
}
}
},
"smsgateway.SimSelectionMode": {
"type": "string",
"enum": [
"OSDefault",
"RoundRobin",
"Random"
],
"x-enum-varnames": [
"OSDefault",
"RoundRobin",
"Random"
]
},
"smsgateway.Webhook": {
"type": "object",
"required": [

View File

@ -26,6 +26,34 @@ definitions:
example: "2020-01-01T00:00:00Z"
type: string
type: object
smsgateway.DeviceSettings:
properties:
encryption:
allOf:
- $ref: '#/definitions/smsgateway.SettingsEncryption'
description: Encryption contains settings related to message encryption.
gateway:
allOf:
- $ref: '#/definitions/smsgateway.SettingsGateway'
description: Gateway contains settings related to the Cloud/Private server
configuration.
logs:
allOf:
- $ref: '#/definitions/smsgateway.SettingsLogs'
description: Logs contains settings related to logging.
messages:
allOf:
- $ref: '#/definitions/smsgateway.SettingsMessages'
description: Messages contains settings related to message handling.
ping:
allOf:
- $ref: '#/definitions/smsgateway.SettingsPing'
description: Ping contains settings related to ping functionality.
webhooks:
allOf:
- $ref: '#/definitions/smsgateway.SettingsWebhooks'
description: Webhooks contains settings related to webhook functionality.
type: object
smsgateway.ErrorResponse:
properties:
code:
@ -91,6 +119,18 @@ definitions:
- HealthStatusPass
- HealthStatusWarn
- HealthStatusFail
smsgateway.LimitPeriod:
enum:
- Disabled
- PerMinute
- PerHour
- PerDay
type: string
x-enum-varnames:
- Disabled
- PerMinute
- PerHour
- PerDay
smsgateway.LogEntry:
properties:
context:
@ -420,11 +460,13 @@ definitions:
- MessageEnqueued
- WebhooksUpdated
- MessagesExportRequested
- SettingsUpdated
type: string
x-enum-varnames:
- PushMessageEnqueued
- PushWebhooksUpdated
- PushMessagesExportRequested
- PushSettingsUpdated
smsgateway.PushNotification:
properties:
data:
@ -441,6 +483,7 @@ definitions:
- MessageEnqueued
- WebhooksUpdated
- MessagesExportRequested
- SettingsUpdated
example: MessageEnqueued
token:
description: The token of the device that receives the notification.
@ -470,6 +513,117 @@ definitions:
- phoneNumber
- state
type: object
smsgateway.SettingsEncryption:
properties:
passphrase:
description: Passphrase is the encryption passphrase. If nil or empty, encryption
is disabled.
type: string
type: object
smsgateway.SettingsGateway:
properties:
cloud_url:
description: |-
CloudURL is the URL of the server. If nil, the Cloud Public server is used.
Must be a valid HTTPs URL when provided.
type: string
private_token:
description: PrivateToken is the private token for authenticating with the
Private Server.
type: string
type: object
smsgateway.SettingsLogs:
properties:
lifetime_days:
description: |-
LifetimeDays is the number of days to retain logs.
Must be at least 1 when provided.
minimum: 1
type: integer
type: object
smsgateway.SettingsMessages:
properties:
limit_period:
allOf:
- $ref: '#/definitions/smsgateway.LimitPeriod'
description: |-
LimitPeriod defines the period for message sending limits.
Valid values are "Disabled", "PerMinute", "PerHour", or "PerDay".
enum:
- Disabled
- PerMinute
- PerHour
- PerDay
limit_value:
description: |-
LimitValue is the maximum number of messages allowed per limit period.
Must be at least 1 when provided.
minimum: 1
type: integer
log_lifetime_days:
description: |-
LogLifetimeDays is the number of days to retain message logs.
Must be at least 1 when provided.
minimum: 1
type: integer
send_interval_max:
description: |-
SendIntervalMax is the maximum interval between message sends (in seconds).
Must be at least 1 when provided and greater than or equal to SendIntervalMin.
minimum: 1
type: integer
send_interval_min:
description: |-
SendIntervalMin is the minimum interval between message sends (in seconds).
Must be at least 1 when provided.
minimum: 1
type: integer
sim_selection_mode:
allOf:
- $ref: '#/definitions/smsgateway.SimSelectionMode'
description: |-
SimSelectionMode defines how SIM cards are selected for sending messages.
Valid values are "OSDefault", "RoundRobin", or "Random".
enum:
- OSDefault
- RoundRobin
- Random
type: object
smsgateway.SettingsPing:
properties:
interval_seconds:
description: |-
IntervalSeconds is the interval between ping requests (in seconds).
Must be at least 1 when provided.
minimum: 1
type: integer
type: object
smsgateway.SettingsWebhooks:
properties:
internet_required:
description: InternetRequired indicates whether internet access is required
for webhooks.
type: boolean
retry_count:
description: |-
RetryCount is the number of times to retry failed webhook deliveries.
Must be at least 1 when provided.
minimum: 1
type: integer
signing_key:
description: SigningKey is the secret key used for signing webhook payloads.
type: string
type: object
smsgateway.SimSelectionMode:
enum:
- OSDefault
- RoundRobin
- Random
type: string
x-enum-varnames:
- OSDefault
- RoundRobin
- Random
smsgateway.Webhook:
properties:
deviceId:
@ -770,6 +924,102 @@ paths:
tags:
- User
- Messages
/3rdparty/v1/settings:
get:
description: Returns settings for a specific user
produces:
- application/json
responses:
"200":
description: Settings
schema:
$ref: '#/definitions/smsgateway.DeviceSettings'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Get settings
tags:
- User
- Settings
patch:
consumes:
- application/json
description: Partially updates settings for a specific user
parameters:
- description: Settings
in: body
name: request
required: true
schema:
$ref: '#/definitions/smsgateway.DeviceSettings'
produces:
- application/json
responses:
"200":
description: Settings updated
schema:
type: object
"400":
description: Invalid request
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Partially update settings
tags:
- User
- Settings
put:
consumes:
- application/json
description: Updates settings for a specific user
parameters:
- description: Settings
in: body
name: request
required: true
schema:
$ref: '#/definitions/smsgateway.DeviceSettings'
produces:
- application/json
responses:
"200":
description: Settings updated
schema:
type: object
"400":
description: Invalid request
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Update settings
tags:
- User
- Settings
/3rdparty/v1/webhooks:
get:
description: Returns list of registered webhooks
@ -1009,6 +1259,30 @@ paths:
tags:
- Device
- Messages
/mobile/v1/settings:
get:
description: Returns settings for a device
produces:
- application/json
responses:
"200":
description: Settings
schema:
$ref: '#/definitions/smsgateway.DeviceSettings'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- MobileToken: []
summary: Get settings
tags:
- Device
- Settings
/mobile/v1/user/code:
get:
consumes: