Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5d945b
Update OpenAPI schema from dotto-typespec
kantacky Apr 28, 2026
4b62a77
生成コードを最新の OpenAPI スキーマに追従
kantacky Apr 28, 2026
e976572
Notification API 変換を targetUsers 形式に更新
kantacky Apr 28, 2026
d047f40
Notification の通知済みフラグを対象ユーザー単位の NotifiedAt に移行
kantacky Apr 28, 2026
447fc43
Merge remote-tracking branch 'origin/main' into update-db-schema
kantacky Apr 28, 2026
90d67c4
Notification 配信成功ユーザーのみを NotifiedAt 記録対象にする
kantacky Apr 28, 2026
5dbcbbb
fix(repository): isNotified フィルタの OR 句を括弧でグループ化
kantacky Apr 28, 2026
3fbdef8
fix(repository): 通知済みユーザーの notified_at を上書きしない
kantacky Apr 28, 2026
e840b9d
fix(repository): 0件更新の通知IDを Dispatch 結果に含めない
kantacky Apr 28, 2026
e8129b0
fix(repository): UpdateNotification で対象行を SELECT ... FOR UPDATE してレースを防ぐ
kantacky Apr 28, 2026
302d158
fix(service): FCM トークン未登録ユーザーも notifiedAt を埋めて再ディスパッチを止める
kantacky Apr 28, 2026
3af4972
fix(service): トークン混在時もFCM未登録ユーザーの notifiedAt を更新する
kantacky Apr 28, 2026
0011fad
perf(repository): DispatchNotifications を 1 回の UPDATE ... RETURNING に…
kantacky Apr 28, 2026
5b18f82
fix(service): FCM 送信失敗ログに部分成功の件数を明示する
kantacky Apr 28, 2026
ff3b981
perf(repository): DispatchNotifications を UNNEST + JOIN でパラメータ数を一定に保つ
kantacky Apr 28, 2026
1a4eab0
revert(repository): DispatchNotifications を通知ID単位の UPDATE 実装へ戻す
kantacky Apr 28, 2026
7712e63
fix(repository): DispatchNotifications で通知済みユーザーの notified_at も上書きする
kantacky Apr 28, 2026
9041182
fix(service): DispatchNotifications で通知済みユーザーも再送対象に含める
kantacky Apr 28, 2026
36cdec1
refactor(service): NotificationService の messagingClient を interface 化する
kantacky Apr 28, 2026
53411f4
test(service): DispatchNotifications のユニットテストを追加する
kantacky Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions internal/database/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ type Notification struct {

NotifyAfter time.Time `gorm:"type:timestamptz;not null;index"`
NotifyBefore time.Time `gorm:"type:timestamptz;not null;index"`
IsNotified bool `gorm:"type:boolean;not null;default:false;index"`
}

func (n *Notification) ToDomain(targetUserIDs []string) domain.Notification {
func (n *Notification) ToDomain(targetUsers []domain.NotificationTargetUser) domain.Notification {
return domain.Notification{
ID: n.ID,
Title: n.Title,
Expand All @@ -45,8 +44,7 @@ func (n *Notification) ToDomain(targetUserIDs []string) domain.Notification {
URL: n.URL,
NotifyAfter: n.NotifyAfter,
NotifyBefore: n.NotifyBefore,
IsNotified: n.IsNotified,
TargetUserIDs: targetUserIDs,
TargetUsers: targetUsers,
}
}

Expand All @@ -67,6 +65,5 @@ func NotificationFromDomain(n domain.Notification) Notification {
URL: n.URL,
NotifyAfter: n.NotifyAfter,
NotifyBefore: n.NotifyBefore,
IsNotified: n.IsNotified,
}
}
3 changes: 3 additions & 0 deletions internal/database/notification_target_user.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package database

import "time"

type NotificationTargetUser struct {
NotificationID string `gorm:"type:text;primaryKey"`
UserID string `gorm:"type:text;primaryKey"`
NotifiedAt *time.Time `gorm:"type:timestamptz;index"`
Notification Notification `gorm:"constraint:OnDelete:CASCADE"`
User User `gorm:"constraint:OnDelete:CASCADE"`
}
8 changes: 6 additions & 2 deletions internal/domain/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ type Notification struct {

NotifyAfter time.Time
NotifyBefore time.Time
IsNotified bool

TargetUserIDs []string
TargetUsers []NotificationTargetUser
}

type NotificationTargetUser struct {
UserID string
NotifiedAt *time.Time
}
15 changes: 11 additions & 4 deletions internal/handler/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,12 @@ func toDomainFCMToken(req api.FCMTokenRequest) domain.FCMToken {
}

func toAPINotification(n domain.Notification) api.Notification {
targetUsers := make([]api.NotificationTargetUser, 0, len(n.TargetUserIDs))
for _, uid := range n.TargetUserIDs {
targetUsers = append(targetUsers, api.NotificationTargetUser{UserId: uid})
targetUsers := make([]api.NotificationTargetUser, 0, len(n.TargetUsers))
for _, t := range n.TargetUsers {
targetUsers = append(targetUsers, api.NotificationTargetUser{
UserId: t.UserID,
NotifiedAt: t.NotifiedAt,
})
}
return api.Notification{
Id: n.ID,
Expand Down Expand Up @@ -115,6 +118,10 @@ func toAPINotifications(notifications []domain.Notification) []api.Notification
}

func toDomainNotification(id string, req api.NotificationRequest) domain.Notification {
targetUsers := make([]domain.NotificationTargetUser, 0, len(req.TargetUserIds))
for _, uid := range req.TargetUserIds {
targetUsers = append(targetUsers, domain.NotificationTargetUser{UserID: uid})
}
Comment thread
kantacky marked this conversation as resolved.
return domain.Notification{
ID: id,
Title: req.Title,
Expand All @@ -131,7 +138,7 @@ func toDomainNotification(id string, req api.NotificationRequest) domain.Notific
URL: req.Url,
NotifyAfter: req.NotifyAfter,
NotifyBefore: req.NotifyBefore,
TargetUserIDs: req.TargetUserIds,
TargetUsers: targetUsers,
}
}

Expand Down
14 changes: 8 additions & 6 deletions internal/repository/notification_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notific

dbNotification := database.NotificationFromDomain(notification)

uniqueTargets := uniqueTargetUsers(notification.TargetUsers)

err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&dbNotification).Error; err != nil {
return err
}

uniqueIDs := uniqueStrings(notification.TargetUserIDs)
if len(uniqueIDs) > 0 {
targets := make([]database.NotificationTargetUser, 0, len(uniqueIDs))
for _, userID := range uniqueIDs {
if len(uniqueTargets) > 0 {
targets := make([]database.NotificationTargetUser, 0, len(uniqueTargets))
for _, t := range uniqueTargets {
targets = append(targets, database.NotificationTargetUser{
NotificationID: notification.ID,
UserID: userID,
UserID: t.UserID,
NotifiedAt: t.NotifiedAt,
})
}
if err := tx.Create(&targets).Error; err != nil {
Expand All @@ -39,5 +41,5 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notific
return domain.Notification{}, err
}

return dbNotification.ToDomain(uniqueStrings(notification.TargetUserIDs)), nil
return dbNotification.ToDomain(uniqueTargets), nil
}
44 changes: 27 additions & 17 deletions internal/repository/notification_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repository

import (
"context"
"time"

"github.com/fun-dotto/user-api/internal/database"
"github.com/fun-dotto/user-api/internal/domain"
Expand Down Expand Up @@ -31,9 +32,12 @@ func (r *NotificationRepository) GetNotificationsByIDs(ctx context.Context, ids
return nil, err
}

targetMap := make(map[string][]string)
targetMap := make(map[string][]domain.NotificationTargetUser)
for _, t := range allTargets {
targetMap[t.NotificationID] = append(targetMap[t.NotificationID], t.UserID)
targetMap[t.NotificationID] = append(targetMap[t.NotificationID], domain.NotificationTargetUser{
UserID: t.UserID,
NotifiedAt: t.NotifiedAt,
})
}

notifications := make([]domain.Notification, 0, len(dbNotifications))
Expand All @@ -44,26 +48,32 @@ func (r *NotificationRepository) GetNotificationsByIDs(ctx context.Context, ids
return notifications, nil
}

func (r *NotificationRepository) DispatchNotifications(ctx context.Context, ids []string) ([]domain.Notification, error) {
uniqueIDs := uniqueStrings(ids)
if len(uniqueIDs) == 0 {
func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error) {
if len(deliveries) == 0 {
return []domain.Notification{}, nil
}

if err := r.db.WithContext(ctx).Model(&database.Notification{}).
Where("id IN ?", uniqueIDs).
Update("is_notified", true).Error; err != nil {
return nil, err
}

notifications, err := r.GetNotificationsByIDs(ctx, uniqueIDs)
if err != nil {
return nil, err
now := time.Now()
notificationIDs := make([]string, 0, len(deliveries))
for nid, userIDs := range deliveries {
Comment thread
kantacky marked this conversation as resolved.
uniqueUsers := uniqueStrings(userIDs)
if len(uniqueUsers) == 0 {
continue
}
db := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}).
Where("notification_id = ? AND user_id IN ?", nid, uniqueUsers).
Update("notified_at", now)
if db.Error != nil {
return nil, db.Error
}
if db.RowsAffected > 0 {
notificationIDs = append(notificationIDs, nid)
}
Comment thread
kantacky marked this conversation as resolved.
}

for i := range notifications {
notifications[i].IsNotified = true
if len(notificationIDs) == 0 {
return []domain.Notification{}, nil
}

return notifications, nil
return r.GetNotificationsByIDs(ctx, notificationIDs)
}
15 changes: 12 additions & 3 deletions internal/repository/notification_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ func (r *NotificationRepository) ListNotifications(ctx context.Context, filter d
query = query.Where("notify_after <= ?", *filter.NotifyAtTo)
}
if filter.IsNotified != nil {
query = query.Where("is_notified = ?", *filter.IsNotified)
if *filter.IsNotified {
query = query.Where(`NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL)
AND EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id)`)
} else {
query = query.Where(`(EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL)
OR NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id))`)
}
}

var dbNotifications []database.Notification
Expand All @@ -39,9 +45,12 @@ func (r *NotificationRepository) ListNotifications(ctx context.Context, filter d
return nil, err
}

targetMap := make(map[string][]string)
targetMap := make(map[string][]domain.NotificationTargetUser)
for _, t := range allTargets {
targetMap[t.NotificationID] = append(targetMap[t.NotificationID], t.UserID)
targetMap[t.NotificationID] = append(targetMap[t.NotificationID], domain.NotificationTargetUser{
UserID: t.UserID,
NotifiedAt: t.NotifiedAt,
})
}

notifications := make([]domain.Notification, 0, len(dbNotifications))
Expand Down
35 changes: 28 additions & 7 deletions internal/repository/notification_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package repository
import (
"context"
"errors"
"time"

"github.com/fun-dotto/user-api/internal/database"
"github.com/fun-dotto/user-api/internal/domain"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)

func (r *NotificationRepository) UpdateNotification(ctx context.Context, notification domain.Notification) (domain.Notification, error) {
var dbNotification database.Notification
uniqueTargets := uniqueTargetUsers(notification.TargetUsers)

err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing database.Notification
Expand All @@ -23,21 +26,39 @@ func (r *NotificationRepository) UpdateNotification(ctx context.Context, notific

dbNotification = database.NotificationFromDomain(notification)

if err := tx.Omit("IsNotified").Save(&dbNotification).Error; err != nil {
if err := tx.Save(&dbNotification).Error; err != nil {
return err
}

var existingTargets []database.NotificationTargetUser
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("notification_id = ?", notification.ID).
Find(&existingTargets).Error; err != nil {
return err
}
existingNotifiedAt := make(map[string]*time.Time, len(existingTargets))
Comment thread
kantacky marked this conversation as resolved.
for _, t := range existingTargets {
existingNotifiedAt[t.UserID] = t.NotifiedAt
}

if err := tx.Where("notification_id = ?", notification.ID).Delete(&database.NotificationTargetUser{}).Error; err != nil {
return err
}

uniqueIDs := uniqueStrings(notification.TargetUserIDs)
if len(uniqueIDs) > 0 {
targets := make([]database.NotificationTargetUser, 0, len(uniqueIDs))
for _, userID := range uniqueIDs {
if len(uniqueTargets) > 0 {
targets := make([]database.NotificationTargetUser, 0, len(uniqueTargets))
for i, t := range uniqueTargets {
notifiedAt := t.NotifiedAt
if notifiedAt == nil {
if prev, ok := existingNotifiedAt[t.UserID]; ok {
notifiedAt = prev
uniqueTargets[i].NotifiedAt = prev
}
}
targets = append(targets, database.NotificationTargetUser{
NotificationID: notification.ID,
UserID: userID,
UserID: t.UserID,
NotifiedAt: notifiedAt,
})
}
if err := tx.Create(&targets).Error; err != nil {
Expand All @@ -54,5 +75,5 @@ func (r *NotificationRepository) UpdateNotification(ctx context.Context, notific
return domain.Notification{}, err
}

return dbNotification.ToDomain(uniqueStrings(notification.TargetUserIDs)), nil
return dbNotification.ToDomain(uniqueTargets), nil
}
15 changes: 15 additions & 0 deletions internal/repository/util.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package repository

import "github.com/fun-dotto/user-api/internal/domain"

func uniqueStrings(s []string) []string {
seen := make(map[string]struct{}, len(s))
result := make([]string, 0, len(s))
Expand All @@ -12,3 +14,16 @@ func uniqueStrings(s []string) []string {
}
return result
}

func uniqueTargetUsers(targets []domain.NotificationTargetUser) []domain.NotificationTargetUser {
seen := make(map[string]struct{}, len(targets))
result := make([]domain.NotificationTargetUser, 0, len(targets))
for _, t := range targets {
if _, ok := seen[t.UserID]; ok {
continue
}
seen[t.UserID] = struct{}{}
result = append(result, t)
}
return result
}
14 changes: 9 additions & 5 deletions internal/service/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,27 @@ type NotificationRepository interface {
UpdateNotification(ctx context.Context, notification domain.Notification) (domain.Notification, error)
DeleteNotification(ctx context.Context, id string) error
GetNotificationsByIDs(ctx context.Context, ids []string) ([]domain.Notification, error)
DispatchNotifications(ctx context.Context, ids []string) ([]domain.Notification, error)
DispatchNotifications(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error)
}

type FCMTokenRepositoryForNotification interface {
ListFCMTokens(ctx context.Context, filter domain.FCMTokenListFilter) ([]domain.FCMToken, error)
}

type MessagingClient interface {
SendEachForMulticast(ctx context.Context, message *messaging.MulticastMessage) (*messaging.BatchResponse, error)
}

type NotificationService struct {
repo NotificationRepository
fcmTokenRepo FCMTokenRepositoryForNotification
messagingClient *messaging.Client
repo NotificationRepository
fcmTokenRepo FCMTokenRepositoryForNotification
messagingClient MessagingClient
}

func NewNotificationService(
repo NotificationRepository,
fcmTokenRepo FCMTokenRepositoryForNotification,
messagingClient *messaging.Client,
messagingClient MessagingClient,
) *NotificationService {
return &NotificationService{
repo: repo,
Expand Down
Loading
Loading