diff --git a/generated/api.gen.go b/generated/api.gen.go index 39d53a0..e513db5 100644 --- a/generated/api.gen.go +++ b/generated/api.gen.go @@ -161,11 +161,55 @@ type FCMTokenRequest struct { // Notification defines model for Notification. type Notification struct { + // AnalyticsLabel Firebase Analytics に記録する分析ラベル + // + // 通知の開封率計測などに利用 + AnalyticsLabel *string `json:"analyticsLabel,omitempty"` + + // AndroidChannelId Android の通知チャンネルID + // + // Android 8.0 以降、通知はチャンネル単位で管理される + AndroidChannelId *string `json:"androidChannelId,omitempty"` + + // AndroidPriority Android の通知優先度 + // + // "normal" または "high"。"high" は即時配信される + AndroidPriority *string `json:"androidPriority,omitempty"` + + // AndroidTtlSeconds Android の通知TTL(秒) + // + // FCM がメッセージを保持する最大時間。期限切れで破棄される + AndroidTtlSeconds *int32 `json:"androidTtlSeconds,omitempty"` + + // ApnsBadge APNs のバッジ数 + // + // アプリアイコンに表示される数値。0 でバッジを消去 + ApnsBadge *int32 `json:"apnsBadge,omitempty"` + + // ApnsContentAvailable APNs の content-available フラグ + // + // true でサイレントプッシュ(バックグラウンド更新)になる + ApnsContentAvailable *bool `json:"apnsContentAvailable,omitempty"` + + // ApnsSound APNs の通知音 + // + // - "default": OS 標準の通知音 + // - "" または省略: 無音 + // - 任意のファイル名(例: "alert.caf"): アプリバンドルまたは Library/Sounds/ に同梱されたカスタムサウンド + // 対応フォーマットは .caf / .aiff / .wav(Linear PCM, MA4, µ-law, a-law)、最大30秒 + ApnsSound *string `json:"apnsSound,omitempty"` + + // Body 通知本文 + Body string `json:"body"` + + // Id 通知ID Id string `json:"id"` + // ImageUrl 通知に表示する画像のURL + ImageUrl *string `json:"imageUrl,omitempty"` + // IsNotified 通知が送信されたかどうか - IsNotified bool `json:"isNotified"` - Message string `json:"message"` + IsNotified bool `json:"isNotified"` // NotifyAfter 通知送信可能になる日時(この時刻以降に送信対象となる) NotifyAfter time.Time `json:"notifyAfter"` @@ -175,21 +219,70 @@ type Notification struct { // TargetUserIds 対象ユーザーIDのリスト TargetUserIds []string `json:"targetUserIds"` - Title string `json:"title"` + + // Title 通知タイトル + Title string `json:"title"` // Url 通知をタップした時に開くURL - // アプリを開くのみの場合はnull + // アプリを開くのみの場合は未指定 Url *string `json:"url,omitempty"` + + // WebpushLink Web Push 通知をクリックした際に開くURL + WebpushLink *string `json:"webpushLink,omitempty"` } // NotificationRequest defines model for NotificationRequest. type NotificationRequest struct { - Message string `json:"message"` - NotifyAfter time.Time `json:"notifyAfter"` - NotifyBefore time.Time `json:"notifyBefore"` - TargetUserIds []string `json:"targetUserIds"` - Title string `json:"title"` - Url *string `json:"url,omitempty"` + // AnalyticsLabel Firebase Analytics に記録する分析ラベル + AnalyticsLabel *string `json:"analyticsLabel,omitempty"` + + // AndroidChannelId Android の通知チャンネルID + AndroidChannelId *string `json:"androidChannelId,omitempty"` + + // AndroidPriority Android の通知優先度("normal" または "high") + AndroidPriority *string `json:"androidPriority,omitempty"` + + // AndroidTtlSeconds Android の通知TTL(秒) + AndroidTtlSeconds *int32 `json:"androidTtlSeconds,omitempty"` + + // ApnsBadge APNs のバッジ数 + ApnsBadge *int32 `json:"apnsBadge,omitempty"` + + // ApnsContentAvailable APNs の content-available フラグ(true でサイレントプッシュ) + ApnsContentAvailable *bool `json:"apnsContentAvailable,omitempty"` + + // ApnsSound APNs の通知音 + // + // - "default": OS 標準の通知音 + // - "" または省略: 無音 + // - 任意のファイル名(例: "alert.caf"): アプリバンドルまたは Library/Sounds/ に同梱されたカスタムサウンド + // 対応フォーマットは .caf / .aiff / .wav(Linear PCM, MA4, µ-law, a-law)、最大30秒 + ApnsSound *string `json:"apnsSound,omitempty"` + + // Body 通知本文 + Body string `json:"body"` + + // ImageUrl 通知に表示する画像のURL + ImageUrl *string `json:"imageUrl,omitempty"` + + // NotifyAfter 通知送信可能になる日時(この時刻以降に送信対象となる) + NotifyAfter time.Time `json:"notifyAfter"` + + // NotifyBefore 通知送信期限日時(この時刻を過ぎた場合は送信しない) + NotifyBefore time.Time `json:"notifyBefore"` + + // TargetUserIds 対象ユーザーIDのリスト + TargetUserIds []string `json:"targetUserIds"` + + // Title 通知タイトル + Title string `json:"title"` + + // Url 通知をタップした時に開くURL + // アプリを開くのみの場合は未指定 + Url *string `json:"url,omitempty"` + + // WebpushLink Web Push 通知をクリックした際に開くURL + WebpushLink *string `json:"webpushLink,omitempty"` } // User defines model for User. diff --git a/internal/database/notification.go b/internal/database/notification.go index 4e3958f..af738d0 100644 --- a/internal/database/notification.go +++ b/internal/database/notification.go @@ -7,10 +7,22 @@ import ( ) type Notification struct { - ID string `gorm:"type:text;primaryKey"` - Title string `gorm:"type:text;not null"` - Message string `gorm:"type:text;not null"` - URL *string `gorm:"type:text"` + ID string `gorm:"type:text;primaryKey"` + + Title string `gorm:"type:text;not null"` + Body string `gorm:"type:text;not null"` + ImageURL *string `gorm:"type:text"` + AnalyticsLabel *string `gorm:"type:text"` + APNsBadge *int `gorm:"type:integer"` + APNsSound *string `gorm:"type:text"` + APNsContentAvailable *bool `gorm:"type:boolean"` + AndroidChannelID *string `gorm:"type:text"` + AndroidPriority *string `gorm:"type:text"` + AndroidTTLSeconds *int `gorm:"type:integer"` + WebpushLink *string `gorm:"type:text"` + + URL *string `gorm:"type:text"` + 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"` @@ -18,25 +30,43 @@ type Notification struct { func (n *Notification) ToDomain(targetUserIDs []string) domain.Notification { return domain.Notification{ - ID: n.ID, - Title: n.Title, - Message: n.Message, - URL: n.URL, - NotifyAfter: n.NotifyAfter, - NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, - TargetUserIDs: targetUserIDs, + ID: n.ID, + Title: n.Title, + Body: n.Body, + ImageURL: n.ImageURL, + AnalyticsLabel: n.AnalyticsLabel, + APNsBadge: n.APNsBadge, + APNsSound: n.APNsSound, + APNsContentAvailable: n.APNsContentAvailable, + AndroidChannelID: n.AndroidChannelID, + AndroidPriority: n.AndroidPriority, + AndroidTTLSeconds: n.AndroidTTLSeconds, + WebpushLink: n.WebpushLink, + URL: n.URL, + NotifyAfter: n.NotifyAfter, + NotifyBefore: n.NotifyBefore, + IsNotified: n.IsNotified, + TargetUserIDs: targetUserIDs, } } func NotificationFromDomain(n domain.Notification) Notification { return Notification{ - ID: n.ID, - Title: n.Title, - Message: n.Message, - URL: n.URL, - NotifyAfter: n.NotifyAfter, - NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, + ID: n.ID, + Title: n.Title, + Body: n.Body, + ImageURL: n.ImageURL, + AnalyticsLabel: n.AnalyticsLabel, + APNsBadge: n.APNsBadge, + APNsSound: n.APNsSound, + APNsContentAvailable: n.APNsContentAvailable, + AndroidChannelID: n.AndroidChannelID, + AndroidPriority: n.AndroidPriority, + AndroidTTLSeconds: n.AndroidTTLSeconds, + WebpushLink: n.WebpushLink, + URL: n.URL, + NotifyAfter: n.NotifyAfter, + NotifyBefore: n.NotifyBefore, + IsNotified: n.IsNotified, } } diff --git a/internal/domain/notification.go b/internal/domain/notification.go index d1a9bd8..9d2373c 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -3,12 +3,25 @@ package domain import "time" type Notification struct { - ID string - Title string - Message string - URL *string - NotifyAfter time.Time - NotifyBefore time.Time - IsNotified bool + ID string + + Title string + Body string + ImageURL *string + AnalyticsLabel *string + APNsBadge *int + APNsSound *string + APNsContentAvailable *bool + AndroidChannelID *string + AndroidPriority *string + AndroidTTLSeconds *int + WebpushLink *string + + URL *string + + NotifyAfter time.Time + NotifyBefore time.Time + IsNotified bool + TargetUserIDs []string } diff --git a/internal/handler/converter.go b/internal/handler/converter.go index 0a16c0d..726e316 100644 --- a/internal/handler/converter.go +++ b/internal/handler/converter.go @@ -82,16 +82,34 @@ func toDomainFCMToken(req api.FCMTokenRequest) domain.FCMToken { } func toAPINotification(n domain.Notification) api.Notification { - return api.Notification{ - Id: n.ID, - Title: n.Title, - Message: n.Message, - Url: n.URL, - NotifyAfter: n.NotifyAfter, - NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, - TargetUserIds: n.TargetUserIDs, - } + notification := api.Notification{ + Id: n.ID, + Title: n.Title, + Body: n.Body, + ImageUrl: n.ImageURL, + AnalyticsLabel: n.AnalyticsLabel, + ApnsSound: n.APNsSound, + ApnsContentAvailable: n.APNsContentAvailable, + AndroidChannelId: n.AndroidChannelID, + AndroidPriority: n.AndroidPriority, + WebpushLink: n.WebpushLink, + Url: n.URL, + NotifyAfter: n.NotifyAfter, + NotifyBefore: n.NotifyBefore, + IsNotified: n.IsNotified, + TargetUserIds: n.TargetUserIDs, + } + + if n.APNsBadge != nil { + badge := int32(*n.APNsBadge) + notification.ApnsBadge = &badge + } + if n.AndroidTTLSeconds != nil { + ttl := int32(*n.AndroidTTLSeconds) + notification.AndroidTtlSeconds = &ttl + } + + return notification } func toAPINotifications(notifications []domain.Notification) []api.Notification { @@ -103,15 +121,33 @@ func toAPINotifications(notifications []domain.Notification) []api.Notification } func toDomainNotification(id string, req api.NotificationRequest) domain.Notification { - return domain.Notification{ - ID: id, - Title: req.Title, - Message: req.Message, - URL: req.Url, - NotifyAfter: req.NotifyAfter, - NotifyBefore: req.NotifyBefore, - TargetUserIDs: req.TargetUserIds, - } + notification := domain.Notification{ + ID: id, + Title: req.Title, + Body: req.Body, + ImageURL: req.ImageUrl, + AnalyticsLabel: req.AnalyticsLabel, + APNsSound: req.ApnsSound, + APNsContentAvailable: req.ApnsContentAvailable, + AndroidChannelID: req.AndroidChannelId, + AndroidPriority: req.AndroidPriority, + WebpushLink: req.WebpushLink, + URL: req.Url, + NotifyAfter: req.NotifyAfter, + NotifyBefore: req.NotifyBefore, + TargetUserIDs: req.TargetUserIds, + } + + if req.ApnsBadge != nil { + badge := int(*req.ApnsBadge) + notification.APNsBadge = &badge + } + if req.AndroidTtlSeconds != nil { + ttl := int(*req.AndroidTtlSeconds) + notification.AndroidTTLSeconds = &ttl + } + + return notification } func toDomainNotificationListFilter(params api.NotificationV1ListParams) domain.NotificationListFilter { diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index 9d65f18..78f8a68 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -3,6 +3,7 @@ package service import ( "context" "log" + "time" "firebase.google.com/go/v4/messaging" "github.com/fun-dotto/user-api/internal/domain" @@ -87,16 +88,34 @@ func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notific data["url"] = *n.URL } + notification := &messaging.Notification{ + Title: n.Title, + Body: n.Body, + } + if n.ImageURL != nil { + notification.ImageURL = *n.ImageURL + } + + var fcmOptions *messaging.FCMOptions + if n.AnalyticsLabel != nil { + fcmOptions = &messaging.FCMOptions{AnalyticsLabel: *n.AnalyticsLabel} + } + + androidConfig := buildAndroidConfig(n) + apnsConfig := buildAPNSConfig(n) + webpushConfig := buildWebpushConfig(n) + totalSuccess := 0 for start := 0; start < len(tokens); start += fcmMulticastBatchSize { end := min(start+fcmMulticastBatchSize, len(tokens)) msg := &messaging.MulticastMessage{ - Tokens: tokens[start:end], - Notification: &messaging.Notification{ - Title: n.Title, - Body: n.Message, - }, - Data: data, + Tokens: tokens[start:end], + Notification: notification, + Data: data, + Android: androidConfig, + APNS: apnsConfig, + Webpush: webpushConfig, + FCMOptions: fcmOptions, } resp, err := s.messagingClient.SendEachForMulticast(ctx, msg) if err != nil { @@ -113,3 +132,47 @@ func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notific } return totalSuccess, nil } + +func buildAndroidConfig(n domain.Notification) *messaging.AndroidConfig { + if n.AndroidChannelID == nil && n.AndroidPriority == nil && n.AndroidTTLSeconds == nil { + return nil + } + cfg := &messaging.AndroidConfig{} + if n.AndroidPriority != nil { + cfg.Priority = *n.AndroidPriority + } + if n.AndroidTTLSeconds != nil { + ttl := time.Duration(*n.AndroidTTLSeconds) * time.Second + cfg.TTL = &ttl + } + if n.AndroidChannelID != nil { + cfg.Notification = &messaging.AndroidNotification{ChannelID: *n.AndroidChannelID} + } + return cfg +} + +func buildAPNSConfig(n domain.Notification) *messaging.APNSConfig { + if n.APNsBadge == nil && n.APNsSound == nil && n.APNsContentAvailable == nil { + return nil + } + aps := &messaging.Aps{} + if n.APNsBadge != nil { + aps.Badge = n.APNsBadge + } + if n.APNsSound != nil { + aps.Sound = *n.APNsSound + } + if n.APNsContentAvailable != nil { + aps.ContentAvailable = *n.APNsContentAvailable + } + return &messaging.APNSConfig{Payload: &messaging.APNSPayload{Aps: aps}} +} + +func buildWebpushConfig(n domain.Notification) *messaging.WebpushConfig { + if n.WebpushLink == nil { + return nil + } + return &messaging.WebpushConfig{ + FCMOptions: &messaging.WebpushFCMOptions{Link: *n.WebpushLink}, + } +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2dbf91e..96725a9 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -403,7 +403,7 @@ components: required: - id - title - - message + - body - notifyAfter - notifyBefore - isNotified @@ -411,15 +411,71 @@ components: properties: id: type: string + description: 通知ID title: type: string - message: + description: 通知タイトル + body: + type: string + description: 通知本文 + imageUrl: + type: string + description: 通知に表示する画像のURL + analyticsLabel: + type: string + description: |- + Firebase Analytics に記録する分析ラベル + + 通知の開封率計測などに利用 + apnsBadge: + type: integer + format: int32 + description: |- + APNs のバッジ数 + + アプリアイコンに表示される数値。0 でバッジを消去 + apnsSound: + type: string + description: |- + APNs の通知音 + + - "default": OS 標準の通知音 + - "" または省略: 無音 + - 任意のファイル名(例: "alert.caf"): アプリバンドルまたは Library/Sounds/ に同梱されたカスタムサウンド + 対応フォーマットは .caf / .aiff / .wav(Linear PCM, MA4, µ-law, a-law)、最大30秒 + apnsContentAvailable: + type: boolean + description: |- + APNs の content-available フラグ + + true でサイレントプッシュ(バックグラウンド更新)になる + androidChannelId: + type: string + description: |- + Android の通知チャンネルID + + Android 8.0 以降、通知はチャンネル単位で管理される + androidPriority: + type: string + description: |- + Android の通知優先度 + + "normal" または "high"。"high" は即時配信される + androidTtlSeconds: + type: integer + format: int32 + description: |- + Android の通知TTL(秒) + + FCM がメッセージを保持する最大時間。期限切れで破棄される + webpushLink: type: string + description: Web Push 通知をクリックした際に開くURL url: type: string description: |- 通知をタップした時に開くURL - アプリを開くのみの場合はnull + アプリを開くのみの場合は未指定 notifyAfter: type: string format: date-time @@ -440,27 +496,70 @@ components: type: object required: - title - - message + - body - notifyAfter - notifyBefore - targetUserIds properties: title: type: string - message: + description: 通知タイトル + body: + type: string + description: 通知本文 + imageUrl: + type: string + description: 通知に表示する画像のURL + analyticsLabel: + type: string + description: Firebase Analytics に記録する分析ラベル + apnsBadge: + type: integer + format: int32 + description: APNs のバッジ数 + apnsSound: + type: string + description: |- + APNs の通知音 + + - "default": OS 標準の通知音 + - "" または省略: 無音 + - 任意のファイル名(例: "alert.caf"): アプリバンドルまたは Library/Sounds/ に同梱されたカスタムサウンド + 対応フォーマットは .caf / .aiff / .wav(Linear PCM, MA4, µ-law, a-law)、最大30秒 + apnsContentAvailable: + type: boolean + description: APNs の content-available フラグ(true でサイレントプッシュ) + androidChannelId: + type: string + description: Android の通知チャンネルID + androidPriority: type: string + description: Android の通知優先度("normal" または "high") + androidTtlSeconds: + type: integer + format: int32 + description: Android の通知TTL(秒) + webpushLink: + type: string + description: Web Push 通知をクリックした際に開くURL url: type: string + description: |- + 通知をタップした時に開くURL + アプリを開くのみの場合は未指定 notifyAfter: type: string format: date-time + description: 通知送信可能になる日時(この時刻以降に送信対象となる) notifyBefore: type: string format: date-time + description: 通知送信期限日時(この時刻を過ぎた場合は送信しない) targetUserIds: type: array items: type: string + description: 対象ユーザーIDのリスト User: type: object required: