From 46be9e03d59e435fa13a220d77463fd9bebeaac5 Mon Sep 17 00:00:00 2001 From: Johnny Luo Date: Thu, 26 Feb 2026 07:56:00 +1100 Subject: [PATCH 1/3] apple-notification: add custom deeplink to notification payload --- service/notification.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/service/notification.go b/service/notification.go index d4455a0..e29b726 100644 --- a/service/notification.go +++ b/service/notification.go @@ -160,8 +160,8 @@ func (s *NotificationService) processAppleNotification(ctx context.Context, devi notification.Topic = appID p := payload.NewPayload().Alert(nil). AlertTitle("Vultisig Keysign request"). - AlertSubtitle("Vault: " + request.VaultName). - AlertBody(request.QRCodeData). + AlertSubtitle("Vault: "+request.VaultName). + Custom("deeplink", request.QRCodeData). Sound("default") notification.Payload = p // See Payload section below From f89b35fb15f9965360d86c997a691be873779dc0 Mon Sep 17 00:00:00 2001 From: Johnny Luo Date: Thu, 26 Feb 2026 08:16:35 +1100 Subject: [PATCH 2/3] device: upsert on register to enforce one record per vault+party Replace unconditional Create with FirstOrInit+Save so that re-registering the same (vault_id, party_name) updates the token/device_type instead of creating a duplicate row. Add a composite unique index on (vault_id, party_name) enforced at the DB level via AutoMigrate. Hard-delete on unregister to prevent soft-deleted rows from blocking the unique index on re-registration. Handle concurrent INSERT races via MySQL error 1062. Co-Authored-By: Claude Sonnet 4.6 --- models/device.go | 4 ++-- storage/database.go | 26 ++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/models/device.go b/models/device.go index 49d1dfb..1777d61 100644 --- a/models/device.go +++ b/models/device.go @@ -7,8 +7,8 @@ import ( ) type Device struct { - VaultId string `gorm:"type:varchar(255);not null" json:"vault_id" binding:"required"` - PartyName string `gorm:"type:varchar(255);not null" json:"party_name" binding:"required"` + VaultId string `gorm:"type:varchar(255);not null;uniqueIndex:idx_vault_party" json:"vault_id" binding:"required"` + PartyName string `gorm:"type:varchar(255);not null;uniqueIndex:idx_vault_party" json:"party_name" binding:"required"` Token string `gorm:"type:text;not null" json:"token" binding:"required"` DeviceType string `gorm:"type:varchar(255);not null" json:"device_type" binding:"required"` // apple, android, or web } diff --git a/storage/database.go b/storage/database.go index e03e25a..7b51c2b 100644 --- a/storage/database.go +++ b/storage/database.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + driver "github.com/go-sql-driver/mysql" "github.com/vultisig/notification/config" "github.com/vultisig/notification/contexthelper" "github.com/vultisig/notification/models" @@ -49,10 +50,22 @@ func (d *Database) RegisterDevice(ctx context.Context, device models.Device) err defer func() { cancel() }() - deviceModel := device.GetDeviceDBModel() - result := d.db.WithContext(newContext).Create(&deviceModel) + var deviceModel models.DeviceDBModel + result := d.db.WithContext(newContext). + Where("vault_id = ? AND party_name = ?", device.VaultId, device.PartyName). + FirstOrInit(&deviceModel) if result.Error != nil { - return fmt.Errorf("failed to register device: %w", result.Error) + return fmt.Errorf("failed to look up device: %w", result.Error) + } + deviceModel.VaultId = device.VaultId + deviceModel.PartyName = device.PartyName + deviceModel.Token = device.Token + deviceModel.DeviceType = device.DeviceType + if err := d.db.WithContext(newContext).Save(&deviceModel).Error; err != nil { + if isDuplicateKeyError(err) { + return nil + } + return fmt.Errorf("failed to register device: %w", err) } return nil } @@ -70,7 +83,7 @@ func (d *Database) UnregisterDevice(ctx context.Context, vaultId, tokenId string defer func() { cancel() }() - result := d.db.WithContext(newContext).Where("vault_id = ? and token = ?", vaultId, tokenId).Delete(&models.DeviceDBModel{}) + result := d.db.WithContext(newContext).Unscoped().Where("vault_id = ? and token = ?", vaultId, tokenId).Delete(&models.DeviceDBModel{}) if result.Error != nil { return fmt.Errorf("failed to unregister device: %w", result.Error) } @@ -128,3 +141,8 @@ func (d *Database) Close() error { return sqlDB.Close() } + +func isDuplicateKeyError(err error) bool { + var mysqlErr *driver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} From 085596de5dce668159d16e3c7e7f653b601fa66c Mon Sep 17 00:00:00 2001 From: Johnny Luo Date: Thu, 26 Feb 2026 08:22:24 +1100 Subject: [PATCH 3/3] device: replace FirstOrInit+Save with atomic ON DUPLICATE KEY UPDATE Use clause.OnConflict so that registering an existing (vault_id, party_name) always overwrites the token and device_type in a single atomic statement, including under concurrent requests. Removes the isDuplicateKeyError helper which is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- storage/database.go | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/storage/database.go b/storage/database.go index 7b51c2b..74669d1 100644 --- a/storage/database.go +++ b/storage/database.go @@ -6,12 +6,12 @@ import ( "fmt" "time" - driver "github.com/go-sql-driver/mysql" "github.com/vultisig/notification/config" "github.com/vultisig/notification/contexthelper" "github.com/vultisig/notification/models" "gorm.io/driver/mysql" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" ) @@ -50,22 +50,15 @@ func (d *Database) RegisterDevice(ctx context.Context, device models.Device) err defer func() { cancel() }() - var deviceModel models.DeviceDBModel + deviceModel := device.GetDeviceDBModel() result := d.db.WithContext(newContext). - Where("vault_id = ? AND party_name = ?", device.VaultId, device.PartyName). - FirstOrInit(&deviceModel) + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "vault_id"}, {Name: "party_name"}}, + DoUpdates: clause.AssignmentColumns([]string{"token", "device_type", "updated_at"}), + }). + Create(&deviceModel) if result.Error != nil { - return fmt.Errorf("failed to look up device: %w", result.Error) - } - deviceModel.VaultId = device.VaultId - deviceModel.PartyName = device.PartyName - deviceModel.Token = device.Token - deviceModel.DeviceType = device.DeviceType - if err := d.db.WithContext(newContext).Save(&deviceModel).Error; err != nil { - if isDuplicateKeyError(err) { - return nil - } - return fmt.Errorf("failed to register device: %w", err) + return fmt.Errorf("failed to register device: %w", result.Error) } return nil } @@ -141,8 +134,3 @@ func (d *Database) Close() error { return sqlDB.Close() } - -func isDuplicateKeyError(err error) bool { - var mysqlErr *driver.MySQLError - return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 -}