[webhooks] set webhooks per-device

This commit is contained in:
Aleksandr Soloshenko 2025-05-16 09:26:36 +07:00 committed by Aleksandr
parent 99791deebe
commit 632ef462ef
13 changed files with 137 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.7
github.com/android-sms-gateway/client-go v1.5.8
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

4
go.sum
View File

@ -28,6 +28,10 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV
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/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

@ -38,7 +38,7 @@ type MobileController struct {
//
// List webhooks
func (h *MobileController) get(device models.Device, c *fiber.Ctx) error {
items, err := h.webhooksSvc.Select(device.UserID)
items, err := h.webhooksSvc.Select(device.UserID, webhooks.WithDeviceID(device.ID, false))
if err != nil {
return fmt.Errorf("can't select webhooks: %w", err)
}

View File

@ -0,0 +1,20 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE `webhooks`
ADD `device_id` char(21);
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE `webhooks`
ADD CONSTRAINT `fk_webhooks_device` FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON DELETE CASCADE;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE INDEX `idx_webhooks_device` ON `webhooks`(`device_id`);
-- +goose StatementEnd
---
-- +goose Down
-- +goose StatementBegin
ALTER TABLE `webhooks` DROP FOREIGN KEY `fk_webhooks_device`;
-- +goose StatementEnd
-- +goose StatementBegin
ALTER TABLE `webhooks` DROP `device_id`;
-- +goose StatementEnd

View File

@ -30,6 +30,22 @@ func (r *repository) Select(filter ...SelectFilter) ([]models.Device, error) {
return devices, f.apply(r.db).Find(&devices).Error
}
// Exists checks if there exists a device with the given filters.
//
// 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(&models.Device{}).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (r *repository) Get(filter ...SelectFilter) (models.Device, error) {
devices, err := r.Select(filter...)
if err != nil {

View File

@ -52,6 +52,17 @@ func (s *Service) Select(userID string, filter ...SelectFilter) ([]models.Device
return s.devices.Select(filter...)
}
// Exists checks if there exists a device that matches the provided filters.
//
// 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) {
filter = append(filter, WithUserID(userID))
return s.devices.Exists(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

View File

@ -6,8 +6,9 @@ import (
func webhookToDTO(model *Webhook) smsgateway.Webhook {
return smsgateway.Webhook{
ID: model.ExtID,
URL: model.URL,
Event: model.Event,
ID: model.ExtID,
DeviceID: model.DeviceID,
URL: model.URL,
Event: model.Event,
}
}

View File

@ -11,10 +11,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"`
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 models.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
User models.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
Device *models.Device `gorm:"foreignKey:DeviceID;constraint:OnDelete:CASCADE"`
models.TimedModel
}

View File

@ -17,8 +17,10 @@ func WithUserID(userID string) SelectFilter {
}
type selectFilter struct {
userID string
extID *string
userID string
extID *string
deviceID *string
deviceIDExact bool
}
func newFilter(filters ...SelectFilter) *selectFilter {
@ -33,10 +35,27 @@ func (f *selectFilter) merge(filters ...SelectFilter) {
}
}
// WithDeviceID creates a SelectFilter that filters by device ID.
// If exact is true, only records with the exact device ID are matched.
// If exact is false, records with the device ID or with a null device ID are matched.
func WithDeviceID(deviceID string, exact bool) SelectFilter {
return func(f *selectFilter) {
f.deviceID = &deviceID
f.deviceIDExact = exact
}
}
func (f *selectFilter) apply(query *gorm.DB) *gorm.DB {
query = query.Where("user_id = ?", f.userID)
if f.extID != nil {
query = query.Where("ext_id = ?", *f.extID)
}
if f.deviceID != nil {
if f.deviceIDExact {
query = query.Where("device_id = ?", *f.deviceID)
} else {
query = query.Where("device_id = ? OR device_id IS NULL", *f.deviceID)
}
}
return query
}

View File

@ -75,18 +75,30 @@ func (s *Service) Replace(userID string, webhook smsgateway.Webhook) error {
webhook.ID = s.idgen()
}
// Check device ownership if deviceID is provided
if webhook.DeviceID != nil {
ok, err := s.devicesSvc.Exists(userID, devices.WithID(*webhook.DeviceID))
if err != nil {
return fmt.Errorf("failed to select devices: %w", err)
}
if !ok {
return newValidationError("device_id", *webhook.DeviceID, devices.ErrNotFound)
}
}
model := Webhook{
ExtID: webhook.ID,
UserID: userID,
URL: webhook.URL,
Event: webhook.Event,
ExtID: webhook.ID,
UserID: userID,
DeviceID: webhook.DeviceID,
URL: webhook.URL,
Event: webhook.Event,
}
if err := s.webhooks.Replace(&model); err != nil {
return fmt.Errorf("can't replace webhook: %w", err)
}
go s.notifyDevices(userID)
go s.notifyDevices(userID, webhook.DeviceID)
return nil
}
@ -99,30 +111,48 @@ func (s *Service) Delete(userID string, filters ...SelectFilter) error {
return fmt.Errorf("can't delete webhooks: %w", err)
}
go s.notifyDevices(userID)
go s.notifyDevices(userID, nil)
return nil
}
// notifyDevices sends a push notification to all devices associated with the given user.
func (s *Service) notifyDevices(userID string) {
s.logger.Info("Notifying devices", zap.String("user_id", userID))
func (s *Service) notifyDevices(userID string, deviceID *string) {
logFields := []zap.Field{
zap.String("user_id", userID),
}
if deviceID != nil {
logFields = append(logFields, zap.String("device_id", *deviceID))
}
devices, err := s.devicesSvc.Select(userID)
s.logger.Info("Notifying devices", logFields...)
var filters []devices.SelectFilter
if deviceID != nil {
filters = []devices.SelectFilter{devices.WithID(*deviceID)}
}
devices, err := s.devicesSvc.Select(userID, filters...)
if err != nil {
s.logger.Error("Failed to select devices", zap.String("user_id", userID), zap.Error(err))
s.logger.Error("Failed to select devices", append(logFields, zap.Error(err))...)
return
}
if len(devices) == 0 {
s.logger.Info("No devices found", logFields...)
return
}
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))
continue
}
if err := s.pushSvc.Enqueue(*device.PushToken, push.NewWebhooksUpdatedEvent()); err != nil {
s.logger.Error("Failed to send push notification", zap.String("user_id", userID), zap.Error(err))
s.logger.Error("Failed to send push notification", zap.String("user_id", userID), zap.String("device_id", device.ID), zap.Error(err))
}
}
s.logger.Info("Notified devices", zap.String("user_id", userID), zap.Int("count", len(devices)))
s.logger.Info("Notified devices", append(logFields, zap.Int("count", len(devices)))...)
}

View File

@ -90,6 +90,7 @@ Content-Type: application/json
{
"id": "MYofX8bTd5Bov0wWFZLRP",
"deviceId": "C0ZGtCNf7-sXTbCtF6JXm",
"url": "https://webhook.site/280a6655-eb68-40b9-b857-af5be37c5303",
"event": "sms:received"
}

View File

@ -1587,6 +1587,12 @@
"url"
],
"properties": {
"deviceId": {
"description": "The unique identifier of the device the webhook is associated with.",
"type": "string",
"maxLength": 21,
"example": "PyDmBQZZXYmyxMwED8Fzy"
},
"event": {
"description": "The type of event the webhook is triggered for.",
"allOf": [

View File

@ -472,6 +472,12 @@ definitions:
type: object
smsgateway.Webhook:
properties:
deviceId:
description: The unique identifier of the device the webhook is associated
with.
example: PyDmBQZZXYmyxMwED8Fzy
maxLength: 21
type: string
event:
allOf:
- $ref: '#/definitions/smsgateway.WebhookEvent'