diff --git a/contracts/binding/binding.go b/contracts/binding/binding.go index 0a05604f0..d6c20f206 100644 --- a/contracts/binding/binding.go +++ b/contracts/binding/binding.go @@ -1,33 +1,34 @@ package binding const ( - Artisan = "goravel.artisan" - Auth = "goravel.auth" - Cache = "goravel.cache" - Config = "goravel.config" - Crypt = "goravel.crypt" - DB = "goravel.db" - Event = "goravel.event" - Gate = "goravel.gate" - Grpc = "goravel.grpc" - Hash = "goravel.hash" - Http = "goravel.http" - Lang = "goravel.lang" - Log = "goravel.log" - Mail = "goravel.mail" - Orm = "goravel.orm" - Process = "goravel.process" - Queue = "goravel.queue" - RateLimiter = "goravel.rate_limiter" - Route = "goravel.route" - Schedule = "goravel.schedule" - Schema = "goravel.schema" - Seeder = "goravel.seeder" - Session = "goravel.session" - Storage = "goravel.storage" - Testing = "goravel.testing" - Validation = "goravel.validation" - View = "goravel.view" + Artisan = "goravel.artisan" + Auth = "goravel.auth" + Cache = "goravel.cache" + Config = "goravel.config" + Crypt = "goravel.crypt" + DB = "goravel.db" + Event = "goravel.event" + Gate = "goravel.gate" + Grpc = "goravel.grpc" + Hash = "goravel.hash" + Http = "goravel.http" + Lang = "goravel.lang" + Log = "goravel.log" + Mail = "goravel.mail" + Orm = "goravel.orm" + Process = "goravel.process" + Queue = "goravel.queue" + RateLimiter = "goravel.rate_limiter" + Route = "goravel.route" + Schedule = "goravel.schedule" + Schema = "goravel.schema" + Seeder = "goravel.seeder" + Session = "goravel.session" + Storage = "goravel.storage" + Testing = "goravel.testing" + Validation = "goravel.validation" + View = "goravel.view" + Notification = "goravel.notification" ) type Relationship struct { diff --git a/contracts/facades/facades.go b/contracts/facades/facades.go index f2762666d..d61b260ff 100644 --- a/contracts/facades/facades.go +++ b/contracts/facades/facades.go @@ -3,61 +3,63 @@ package facades import "github.com/goravel/framework/contracts/binding" const ( - Artisan = "Artisan" - Auth = "Auth" - Cache = "Cache" - Config = "Config" - Crypt = "Crypt" - DB = "DB" - Event = "Event" - Gate = "Gate" - Grpc = "Grpc" - Hash = "Hash" - Http = "Http" - Lang = "Lang" - Log = "Log" - Mail = "Mail" - Process = "Process" - Orm = "Orm" - Queue = "Queue" - RateLimiter = "RateLimiter" - Route = "Route" - Schedule = "Schedule" - Schema = "Schema" - Seeder = "Seeder" - Session = "Session" - Storage = "Storage" - Testing = "Testing" - Validation = "Validation" - View = "View" + Artisan = "Artisan" + Auth = "Auth" + Cache = "Cache" + Config = "Config" + Crypt = "Crypt" + DB = "DB" + Event = "Event" + Gate = "Gate" + Grpc = "Grpc" + Hash = "Hash" + Http = "Http" + Lang = "Lang" + Log = "Log" + Mail = "Mail" + Process = "Process" + Orm = "Orm" + Queue = "Queue" + RateLimiter = "RateLimiter" + Route = "Route" + Schedule = "Schedule" + Schema = "Schema" + Seeder = "Seeder" + Session = "Session" + Storage = "Storage" + Testing = "Testing" + Validation = "Validation" + View = "View" + Notification = "Notification" ) var FacadeToBinding = map[string]string{ - Artisan: binding.Artisan, - Auth: binding.Auth, - Cache: binding.Cache, - Config: binding.Config, - Crypt: binding.Crypt, - DB: binding.DB, - Event: binding.Event, - Gate: binding.Gate, - Grpc: binding.Grpc, - Hash: binding.Hash, - Http: binding.Http, - Lang: binding.Lang, - Log: binding.Log, - Mail: binding.Mail, - Orm: binding.Orm, - Process: binding.Process, - Queue: binding.Queue, - RateLimiter: binding.RateLimiter, - Route: binding.Route, - Schedule: binding.Schedule, - Schema: binding.Schema, - Seeder: binding.Seeder, - Session: binding.Session, - Storage: binding.Storage, - Testing: binding.Testing, - Validation: binding.Validation, - View: binding.View, + Artisan: binding.Artisan, + Auth: binding.Auth, + Cache: binding.Cache, + Config: binding.Config, + Crypt: binding.Crypt, + DB: binding.DB, + Event: binding.Event, + Gate: binding.Gate, + Grpc: binding.Grpc, + Hash: binding.Hash, + Http: binding.Http, + Lang: binding.Lang, + Log: binding.Log, + Mail: binding.Mail, + Orm: binding.Orm, + Process: binding.Process, + Queue: binding.Queue, + RateLimiter: binding.RateLimiter, + Route: binding.Route, + Schedule: binding.Schedule, + Schema: binding.Schema, + Seeder: binding.Seeder, + Session: binding.Session, + Storage: binding.Storage, + Testing: binding.Testing, + Validation: binding.Validation, + View: binding.View, + Notification: binding.Notification, } diff --git a/contracts/foundation/application.go b/contracts/foundation/application.go index cd57329fd..5ccf9eb16 100644 --- a/contracts/foundation/application.go +++ b/contracts/foundation/application.go @@ -2,6 +2,7 @@ package foundation import ( "context" + "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/auth" "github.com/goravel/framework/contracts/auth/access" @@ -166,6 +167,8 @@ type Application interface { MakeValidation() validation.Validation // MakeView resolves the view instance. MakeView() view.View + // MakeNotification resolves the notification instance. + MakeNotification() notification.Notification // MakeSeeder resolves the seeder instance. MakeSeeder() seeder.Facade // MakeWith resolves the given type with the given parameters from the container. diff --git a/contracts/notification/notification.go b/contracts/notification/notification.go new file mode 100644 index 000000000..99c6c1405 --- /dev/null +++ b/contracts/notification/notification.go @@ -0,0 +1,25 @@ +package notification + +type Notification interface { + Send(notifiable Notifiable) error +} + +type Notif interface { + // Via Return to the list of channel names + Via(notifiable Notifiable) []string +} + +type Channel interface { + // Send sends the given notification to the given notifiable. + Send(notifiable Notifiable, notif interface{}) error +} + +type Notifiable interface { + // NotificationParams returns the parameters for the given key. + NotificationParams() map[string]interface{} +} + +type PayloadProvider interface { + // PayloadFor returns prepared payload data for specific channel. + PayloadFor(channel string, notifiable Notifiable) (map[string]interface{}, error) +} diff --git a/facades/facades.go b/facades/facades.go index 6631ec767..b0d9dff7b 100644 --- a/facades/facades.go +++ b/facades/facades.go @@ -2,6 +2,7 @@ package facades import ( "context" + "github.com/goravel/framework/contracts/notification" "github.com/goravel/framework/contracts/auth" "github.com/goravel/framework/contracts/auth/access" @@ -150,3 +151,7 @@ func Validation() validation.Validation { func View() view.View { return App().MakeView() } + +func Notification() notification.Notification { + return App().MakeNotification() +} diff --git a/foundation/container.go b/foundation/container.go index c4fc84a4a..8c1e0e31c 100644 --- a/foundation/container.go +++ b/foundation/container.go @@ -26,6 +26,7 @@ import ( contractshttpclient "github.com/goravel/framework/contracts/http/client" contractslog "github.com/goravel/framework/contracts/log" contractsmail "github.com/goravel/framework/contracts/mail" + contractsotification "github.com/goravel/framework/contracts/notification" contractsprocess "github.com/goravel/framework/contracts/process" contractsqueue "github.com/goravel/framework/contracts/queue" contractsroute "github.com/goravel/framework/contracts/route" @@ -35,6 +36,7 @@ import ( contractstranslation "github.com/goravel/framework/contracts/translation" contractsvalidation "github.com/goravel/framework/contracts/validation" contractsview "github.com/goravel/framework/contracts/view" + "github.com/goravel/framework/support/color" ) @@ -386,6 +388,16 @@ func (r *Container) MakeView() contractsview.View { return instance.(contractsview.View) } +func (r *Container) MakeNotification() contractsotification.Notification { + instance, err := r.Make(facades.FacadeToBinding[facades.Notification]) + if err != nil { + color.Errorln(err) + return nil + } + + return instance.(contractsotification.Notification) +} + func (r *Container) MakeWith(key any, parameters map[string]any) (any, error) { return r.make(key, parameters) } diff --git a/mocks/foundation/Application.go b/mocks/foundation/Application.go index 30d3fbdbd..377d149e6 100644 --- a/mocks/foundation/Application.go +++ b/mocks/foundation/Application.go @@ -38,6 +38,8 @@ import ( mock "github.com/stretchr/testify/mock" + notification "github.com/goravel/framework/contracts/notification" + orm "github.com/goravel/framework/contracts/database/orm" process "github.com/goravel/framework/contracts/process" @@ -1671,6 +1673,53 @@ func (_c *Application_MakeMail_Call) RunAndReturn(run func() mail.Mail) *Applica return _c } +// MakeNotification provides a mock function with no fields +func (_m *Application) MakeNotification() notification.Notification { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for MakeNotification") + } + + var r0 notification.Notification + if rf, ok := ret.Get(0).(func() notification.Notification); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(notification.Notification) + } + } + + return r0 +} + +// Application_MakeNotification_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MakeNotification' +type Application_MakeNotification_Call struct { + *mock.Call +} + +// MakeNotification is a helper method to define mock.On call +func (_e *Application_Expecter) MakeNotification() *Application_MakeNotification_Call { + return &Application_MakeNotification_Call{Call: _e.mock.On("MakeNotification")} +} + +func (_c *Application_MakeNotification_Call) Run(run func()) *Application_MakeNotification_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Application_MakeNotification_Call) Return(_a0 notification.Notification) *Application_MakeNotification_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Application_MakeNotification_Call) RunAndReturn(run func() notification.Notification) *Application_MakeNotification_Call { + _c.Call.Return(run) + return _c +} + // MakeOrm provides a mock function with no fields func (_m *Application) MakeOrm() orm.Orm { ret := _m.Called() diff --git a/mocks/notification/Channel.go b/mocks/notification/Channel.go new file mode 100644 index 000000000..d96eeed9f --- /dev/null +++ b/mocks/notification/Channel.go @@ -0,0 +1,82 @@ +// Code generated by mockery. DO NOT EDIT. + +package notification + +import ( + notification "github.com/goravel/framework/contracts/notification" + mock "github.com/stretchr/testify/mock" +) + +// Channel is an autogenerated mock type for the Channel type +type Channel struct { + mock.Mock +} + +type Channel_Expecter struct { + mock *mock.Mock +} + +func (_m *Channel) EXPECT() *Channel_Expecter { + return &Channel_Expecter{mock: &_m.Mock} +} + +// Send provides a mock function with given fields: notifiable, notif +func (_m *Channel) Send(notifiable notification.Notifiable, notif interface{}) error { + ret := _m.Called(notifiable, notif) + + if len(ret) == 0 { + panic("no return value specified for Send") + } + + var r0 error + if rf, ok := ret.Get(0).(func(notification.Notifiable, interface{}) error); ok { + r0 = rf(notifiable, notif) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Channel_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send' +type Channel_Send_Call struct { + *mock.Call +} + +// Send is a helper method to define mock.On call +// - notifiable notification.Notifiable +// - notif interface{} +func (_e *Channel_Expecter) Send(notifiable interface{}, notif interface{}) *Channel_Send_Call { + return &Channel_Send_Call{Call: _e.mock.On("Send", notifiable, notif)} +} + +func (_c *Channel_Send_Call) Run(run func(notifiable notification.Notifiable, notif interface{})) *Channel_Send_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(notification.Notifiable), args[1].(interface{})) + }) + return _c +} + +func (_c *Channel_Send_Call) Return(_a0 error) *Channel_Send_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Channel_Send_Call) RunAndReturn(run func(notification.Notifiable, interface{}) error) *Channel_Send_Call { + _c.Call.Return(run) + return _c +} + +// NewChannel creates a new instance of Channel. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewChannel(t interface { + mock.TestingT + Cleanup(func()) +}) *Channel { + mock := &Channel{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/notification/Notif.go b/mocks/notification/Notif.go new file mode 100644 index 000000000..5a45b833e --- /dev/null +++ b/mocks/notification/Notif.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package notification + +import ( + notification "github.com/goravel/framework/contracts/notification" + mock "github.com/stretchr/testify/mock" +) + +// Notif is an autogenerated mock type for the Notif type +type Notif struct { + mock.Mock +} + +type Notif_Expecter struct { + mock *mock.Mock +} + +func (_m *Notif) EXPECT() *Notif_Expecter { + return &Notif_Expecter{mock: &_m.Mock} +} + +// Via provides a mock function with given fields: notifiable +func (_m *Notif) Via(notifiable notification.Notifiable) []string { + ret := _m.Called(notifiable) + + if len(ret) == 0 { + panic("no return value specified for Via") + } + + var r0 []string + if rf, ok := ret.Get(0).(func(notification.Notifiable) []string); ok { + r0 = rf(notifiable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + return r0 +} + +// Notif_Via_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Via' +type Notif_Via_Call struct { + *mock.Call +} + +// Via is a helper method to define mock.On call +// - notifiable notification.Notifiable +func (_e *Notif_Expecter) Via(notifiable interface{}) *Notif_Via_Call { + return &Notif_Via_Call{Call: _e.mock.On("Via", notifiable)} +} + +func (_c *Notif_Via_Call) Run(run func(notifiable notification.Notifiable)) *Notif_Via_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(notification.Notifiable)) + }) + return _c +} + +func (_c *Notif_Via_Call) Return(_a0 []string) *Notif_Via_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Notif_Via_Call) RunAndReturn(run func(notification.Notifiable) []string) *Notif_Via_Call { + _c.Call.Return(run) + return _c +} + +// NewNotif creates a new instance of Notif. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewNotif(t interface { + mock.TestingT + Cleanup(func()) +}) *Notif { + mock := &Notif{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/notification/Notifiable.go b/mocks/notification/Notifiable.go new file mode 100644 index 000000000..3946f9b37 --- /dev/null +++ b/mocks/notification/Notifiable.go @@ -0,0 +1,79 @@ +// Code generated by mockery. DO NOT EDIT. + +package notification + +import mock "github.com/stretchr/testify/mock" + +// Notifiable is an autogenerated mock type for the Notifiable type +type Notifiable struct { + mock.Mock +} + +type Notifiable_Expecter struct { + mock *mock.Mock +} + +func (_m *Notifiable) EXPECT() *Notifiable_Expecter { + return &Notifiable_Expecter{mock: &_m.Mock} +} + +// NotificationParams provides a mock function with no fields +func (_m *Notifiable) NotificationParams() map[string]interface{} { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for NotificationParams") + } + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func() map[string]interface{}); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + return r0 +} + +// Notifiable_NotificationParams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NotificationParams' +type Notifiable_NotificationParams_Call struct { + *mock.Call +} + +// NotificationParams is a helper method to define mock.On call +func (_e *Notifiable_Expecter) NotificationParams() *Notifiable_NotificationParams_Call { + return &Notifiable_NotificationParams_Call{Call: _e.mock.On("NotificationParams")} +} + +func (_c *Notifiable_NotificationParams_Call) Run(run func()) *Notifiable_NotificationParams_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Notifiable_NotificationParams_Call) Return(_a0 map[string]interface{}) *Notifiable_NotificationParams_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Notifiable_NotificationParams_Call) RunAndReturn(run func() map[string]interface{}) *Notifiable_NotificationParams_Call { + _c.Call.Return(run) + return _c +} + +// NewNotifiable creates a new instance of Notifiable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewNotifiable(t interface { + mock.TestingT + Cleanup(func()) +}) *Notifiable { + mock := &Notifiable{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/notification/Notification.go b/mocks/notification/Notification.go new file mode 100644 index 000000000..8fcf8041d --- /dev/null +++ b/mocks/notification/Notification.go @@ -0,0 +1,81 @@ +// Code generated by mockery. DO NOT EDIT. + +package notification + +import ( + notification "github.com/goravel/framework/contracts/notification" + mock "github.com/stretchr/testify/mock" +) + +// Notification is an autogenerated mock type for the Notification type +type Notification struct { + mock.Mock +} + +type Notification_Expecter struct { + mock *mock.Mock +} + +func (_m *Notification) EXPECT() *Notification_Expecter { + return &Notification_Expecter{mock: &_m.Mock} +} + +// Send provides a mock function with given fields: notifiable +func (_m *Notification) Send(notifiable notification.Notifiable) error { + ret := _m.Called(notifiable) + + if len(ret) == 0 { + panic("no return value specified for Send") + } + + var r0 error + if rf, ok := ret.Get(0).(func(notification.Notifiable) error); ok { + r0 = rf(notifiable) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Notification_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send' +type Notification_Send_Call struct { + *mock.Call +} + +// Send is a helper method to define mock.On call +// - notifiable notification.Notifiable +func (_e *Notification_Expecter) Send(notifiable interface{}) *Notification_Send_Call { + return &Notification_Send_Call{Call: _e.mock.On("Send", notifiable)} +} + +func (_c *Notification_Send_Call) Run(run func(notifiable notification.Notifiable)) *Notification_Send_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(notification.Notifiable)) + }) + return _c +} + +func (_c *Notification_Send_Call) Return(_a0 error) *Notification_Send_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Notification_Send_Call) RunAndReturn(run func(notification.Notifiable) error) *Notification_Send_Call { + _c.Call.Return(run) + return _c +} + +// NewNotification creates a new instance of Notification. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewNotification(t interface { + mock.TestingT + Cleanup(func()) +}) *Notification { + mock := &Notification{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mocks/notification/PayloadProvider.go b/mocks/notification/PayloadProvider.go new file mode 100644 index 000000000..9ae23b596 --- /dev/null +++ b/mocks/notification/PayloadProvider.go @@ -0,0 +1,94 @@ +// Code generated by mockery. DO NOT EDIT. + +package notification + +import ( + notification "github.com/goravel/framework/contracts/notification" + mock "github.com/stretchr/testify/mock" +) + +// PayloadProvider is an autogenerated mock type for the PayloadProvider type +type PayloadProvider struct { + mock.Mock +} + +type PayloadProvider_Expecter struct { + mock *mock.Mock +} + +func (_m *PayloadProvider) EXPECT() *PayloadProvider_Expecter { + return &PayloadProvider_Expecter{mock: &_m.Mock} +} + +// PayloadFor provides a mock function with given fields: channel, notifiable +func (_m *PayloadProvider) PayloadFor(channel string, notifiable notification.Notifiable) (map[string]interface{}, error) { + ret := _m.Called(channel, notifiable) + + if len(ret) == 0 { + panic("no return value specified for PayloadFor") + } + + var r0 map[string]interface{} + var r1 error + if rf, ok := ret.Get(0).(func(string, notification.Notifiable) (map[string]interface{}, error)); ok { + return rf(channel, notifiable) + } + if rf, ok := ret.Get(0).(func(string, notification.Notifiable) map[string]interface{}); ok { + r0 = rf(channel, notifiable) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + if rf, ok := ret.Get(1).(func(string, notification.Notifiable) error); ok { + r1 = rf(channel, notifiable) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PayloadProvider_PayloadFor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PayloadFor' +type PayloadProvider_PayloadFor_Call struct { + *mock.Call +} + +// PayloadFor is a helper method to define mock.On call +// - channel string +// - notifiable notification.Notifiable +func (_e *PayloadProvider_Expecter) PayloadFor(channel interface{}, notifiable interface{}) *PayloadProvider_PayloadFor_Call { + return &PayloadProvider_PayloadFor_Call{Call: _e.mock.On("PayloadFor", channel, notifiable)} +} + +func (_c *PayloadProvider_PayloadFor_Call) Run(run func(channel string, notifiable notification.Notifiable)) *PayloadProvider_PayloadFor_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(notification.Notifiable)) + }) + return _c +} + +func (_c *PayloadProvider_PayloadFor_Call) Return(_a0 map[string]interface{}, _a1 error) *PayloadProvider_PayloadFor_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PayloadProvider_PayloadFor_Call) RunAndReturn(run func(string, notification.Notifiable) (map[string]interface{}, error)) *PayloadProvider_PayloadFor_Call { + _c.Call.Return(run) + return _c +} + +// NewPayloadProvider creates a new instance of PayloadProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPayloadProvider(t interface { + mock.TestingT + Cleanup(func()) +}) *PayloadProvider { + mock := &PayloadProvider{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/notification/application.go b/notification/application.go new file mode 100644 index 000000000..1e8665b1c --- /dev/null +++ b/notification/application.go @@ -0,0 +1,38 @@ +package notification + +import ( + "github.com/goravel/framework/contracts/config" + contractsqueuedb "github.com/goravel/framework/contracts/database/db" + contractsmail "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/notification" + contractsqueue "github.com/goravel/framework/contracts/queue" +) + +// Application provides a facade-backed entry point for sending notifications. +// It wires configuration, queue, database, and mail facades needed by channels. +type Application struct { + config config.Config + queue contractsqueue.Queue + db contractsqueuedb.DB + mail contractsmail.Mail +} + +// NewApplication constructs an Application for the notification module. +func NewApplication(config config.Config, queue contractsqueue.Queue, db contractsqueuedb.DB, mail contractsmail.Mail) (*Application, error) { + return &Application{ + config: config, + queue: queue, + db: db, + mail: mail, + }, nil +} + +// Send enqueues a notification to be processed asynchronously. +func (r *Application) Send(notifiables []notification.Notifiable, notif notification.Notif) error { + return (NewNotificationSender(r.db, r.mail, r.queue)).Send(notifiables, notif) +} + +// SendNow sends a notification immediately without queueing. +func (r *Application) SendNow(notifiables []notification.Notifiable, notif notification.Notif) error { + return (NewNotificationSender(r.db, r.mail, nil)).SendNow(notifiables, notif) +} diff --git a/notification/application_test.go b/notification/application_test.go new file mode 100644 index 000000000..25d569c5d --- /dev/null +++ b/notification/application_test.go @@ -0,0 +1,323 @@ +package notification + +import ( + "bytes" + "encoding/gob" + "fmt" + "github.com/google/uuid" + "github.com/goravel/framework/contracts/notification" + contractsqueue "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/foundation/json" + "github.com/goravel/framework/mail" + mocksconfig "github.com/goravel/framework/mocks/config" + mocksdb "github.com/goravel/framework/mocks/database/db" + "github.com/goravel/framework/notification/channels" + "github.com/goravel/framework/notification/models" + "github.com/goravel/framework/queue" + "github.com/goravel/framework/support" + "github.com/goravel/framework/support/color" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/str" + "github.com/spf13/viper" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "os" + "testing" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Phone string `json:"phone"` +} + +func (u User) NotificationParams() map[string]interface{} { + return map[string]interface{}{ + "id": u.ID, + "email": u.Email, + } +} + +type RegisterSuccessNotification struct { + Title string + Content string +} + +// New +func NewRegisterSuccessNotification(title, content string) *RegisterSuccessNotification { + return &RegisterSuccessNotification{ + Title: title, + Content: content, + } +} + +func (n RegisterSuccessNotification) Via(notifiable notification.Notifiable) []string { + return []string{ + "mail", + } +} +func (n RegisterSuccessNotification) ToMail(notifiable notification.Notifiable) map[string]string { + return map[string]string{ + "subject": n.Title, + "content": n.Content, + } +} + +type LoginSuccessNotification struct { + Title string + Content string +} + +func NewLoginSuccessNotification(title, content string) *LoginSuccessNotification { + return &LoginSuccessNotification{ + Title: title, + Content: content, + } +} + +func (n LoginSuccessNotification) Via(notifiable notification.Notifiable) []string { + return []string{ + "database", + } +} +func (n LoginSuccessNotification) ToDatabase(notifiable notification.Notifiable) map[string]string { + return map[string]string{ + "title": n.Title, + "content": n.Content, + } +} + +type ApplicationTestSuite struct { + suite.Suite + mockConfig *mocksconfig.Config +} + +func TestApplicationTestSuite(t *testing.T) { + if !file.Exists(support.EnvFilePath) && os.Getenv("MAIL_HOST") == "" { + color.Errorln("No notification tests run, need create .env based on .env.example, then initialize it") + return + } + + suite.Run(t, new(ApplicationTestSuite)) +} + +func (s *ApplicationTestSuite) SetupTest() { +} + +func (s *ApplicationTestSuite) TestMailNotification() { + s.mockConfig = mockConfig(465) + + queueFacade := mockQueueFacade(s.mockConfig) + + mailFacade, err := mail.NewApplication(s.mockConfig, nil) + s.Nil(err) + + app, err := NewApplication(s.mockConfig, queueFacade, nil, mailFacade) + s.Nil(err) + + var user = User{ + ID: "1", + Email: "657873584@qq.com", + Name: "test", + } + + var registerSuccessNotification = NewRegisterSuccessNotification("Registration successful!", "Congratulations, your registration is successful!") + + RegisterChannel("mail", &channels.MailChannel{}) + + users := []notification.Notifiable{user} + err = app.SendNow(users, registerSuccessNotification) + s.Nil(err) +} + +func (s *ApplicationTestSuite) TestMailNotificationOnQueue() { + s.mockConfig = mockConfig(465) + + queueFacade := mockQueueFacade(s.mockConfig) + + mailFacade, err := mail.NewApplication(s.mockConfig, nil) + s.Nil(err) + + app, err := NewApplication(s.mockConfig, queueFacade, nil, mailFacade) + s.Nil(err) + + var user = User{ + ID: "1", + Email: "657873584@qq.com", + Name: "test", + } + + var registerSuccessNotification = NewRegisterSuccessNotification("Registration successful!", "Congratulations, your registration is successful!") + + RegisterChannel("mail", &channels.MailChannel{}) + + users := []notification.Notifiable{user} + err = app.Send(users, registerSuccessNotification) + s.Nil(err) +} + +func (s *ApplicationTestSuite) TestDatabaseNotification() { + var user = User{ + ID: "1", + Email: "657873584@qq.com", + Name: "test", + } + var loginSuccessNotification = NewLoginSuccessNotification("Login success", "Congratulations, your login is successful!") + + s.mockConfig = mockConfig(465) + + mockDB := mocksdb.NewDB(s.T()) + s.mockConfig.EXPECT().GetString("DB_CONNECTION").Return("mysql").Once() + mockQuery := mocksdb.NewQuery(s.T()) + mockDB.EXPECT().Table("notifications").Return(mockQuery).Once() + + var notificationModel models.Notification + notificationModel.ID = uuid.New().String() + notificationModel.Data = "{\"content\":\"Congratulations, your login is successful!\",\"title\":\"Login success\"}" + notificationModel.NotifiableId = user.ID + notificationModel.NotifiableType = str.Of(fmt.Sprintf("%T", user)).Replace("*", "").String() + notificationModel.Type = fmt.Sprintf("%T", loginSuccessNotification) + + mockQuery.EXPECT().Insert(mock.MatchedBy(func(model *models.Notification) bool { + return model.Data == "{\"content\":\"Congratulations, your login is successful!\",\"title\":\"Login success\"}" && + model.NotifiableId == user.ID && + model.NotifiableType == str.Of(fmt.Sprintf("%T", user)).Replace("*", "").String() && + model.Type == str.Of(fmt.Sprintf("%T", loginSuccessNotification)).Replace("*", "").String() + })).Return(nil, nil).Once() + + app, err := NewApplication(s.mockConfig, nil, mockDB, nil) + s.Nil(err) + + RegisterChannel("database", &channels.DatabaseChannel{}) + + users := []notification.Notifiable{user} + err = app.SendNow(users, loginSuccessNotification) + s.Nil(err) +} + +func (s *ApplicationTestSuite) TestDatabaseNotificationOnQueue() { + var user = User{ + ID: "1", + Email: "657873584@qq.com", + Name: "test", + } + + var loginSuccessNotification = NewLoginSuccessNotification("Login success", "Congratulations, your login is successful!") + + s.mockConfig = mockConfig(465) + queueFacade := mockQueueFacade(s.mockConfig) + + mockDB := mocksdb.NewDB(s.T()) + s.mockConfig.EXPECT().GetString("DB_CONNECTION").Return("mysql").Once() + mockQuery := mocksdb.NewQuery(s.T()) + mockDB.EXPECT().Table("notifications").Return(mockQuery).Once() + + var notificationModel models.Notification + notificationModel.ID = uuid.New().String() + notificationModel.Data = "{\"content\":\"Congratulations, your login is successful!\",\"title\":\"Login success\"}" + notificationModel.NotifiableId = user.ID + notificationModel.NotifiableType = str.Of(fmt.Sprintf("%T", user)).Replace("*", "").String() + notificationModel.Type = fmt.Sprintf("%T", loginSuccessNotification) + + mockQuery.EXPECT().Insert(mock.MatchedBy(func(model *models.Notification) bool { + return model.Data == "{\"content\":\"Congratulations, your login is successful!\",\"title\":\"Login success\"}" && + model.NotifiableId == user.ID && + model.NotifiableType == str.Of(fmt.Sprintf("%T", user)).Replace("*", "").String() && + model.Type == str.Of(fmt.Sprintf("%T", loginSuccessNotification)).Replace("*", "").String() + })).Return(nil, nil).Once() + + app, err := NewApplication(s.mockConfig, queueFacade, mockDB, nil) + s.Nil(err) + + RegisterChannel("database", &channels.DatabaseChannel{}) + + users := []notification.Notifiable{user} + err = app.Send(users, loginSuccessNotification) + s.Nil(err) +} + +func (s *ApplicationTestSuite) TestNotifiableSerialize() { + var loginSuccessNotification = NewLoginSuccessNotification("Login success", "Congratulations, your login is successful!") + // 创建数据缓冲区 + var buf bytes.Buffer + // 创建编码器 + encoder := gob.NewEncoder(&buf) + err := encoder.Encode(loginSuccessNotification) + s.Nil(err) + var loginSuccessNotification2 LoginSuccessNotification + decoder := gob.NewDecoder(&buf) + err = decoder.Decode(&loginSuccessNotification2) + s.Nil(err) + s.Equal(loginSuccessNotification.Title, loginSuccessNotification2.Title) + s.Equal(loginSuccessNotification.Content, loginSuccessNotification2.Content) +} + +func mockQueueFacade(mockConfig *mocksconfig.Config) contractsqueue.Queue { + mockConfig.EXPECT().GetString("queue.default").Return("redis").Once() + mockConfig.EXPECT().GetString("queue.connections.redis.queue", "default").Return("default").Once() + mockConfig.EXPECT().GetInt("queue.connections.redis.concurrent", 1).Return(2).Once() + mockConfig.EXPECT().GetString("app.name", "goravel").Return("goravel").Once() + mockConfig.EXPECT().GetBool("app.debug").Return(true).Once() + mockConfig.EXPECT().GetString("queue.failed.database").Return("mysql").Once() + mockConfig.EXPECT().GetString("queue.failed.table").Return("failed_jobs").Once() + + queueFacade := queue.NewApplication(queue.NewConfig(mockConfig), nil, queue.NewJobStorer(), json.New(), nil) + queueFacade.Register([]contractsqueue.Job{ + NewSendNotificationJob(mockConfig, nil, nil), + }) + return queueFacade +} + +func mockConfig(mailPort int) *mocksconfig.Config { + config := &mocksconfig.Config{} + config.On("GetString", "app.name").Return("goravel") + config.On("GetString", "queue.default").Return("sync") + config.On("GetString", "queue.connections.sync.queue", "default").Return("default") + config.On("GetString", "queue.connections.sync.driver").Return("sync") + config.On("GetInt", "queue.connections.sync.concurrent", 1).Return(1) + config.On("GetString", "queue.failed.database").Return("database") + config.On("GetString", "queue.failed.table").Return("failed_jobs") + + if file.Exists(support.EnvFilePath) { + vip := viper.New() + vip.SetConfigName(support.EnvFilePath) + vip.SetConfigType("env") + vip.AddConfigPath(".") + _ = vip.ReadInConfig() + vip.SetEnvPrefix("goravel") + vip.AutomaticEnv() + + config.On("GetString", "mail.host").Return(vip.Get("MAIL_HOST")) + config.On("GetInt", "mail.port").Return(mailPort) + config.On("GetString", "mail.from.address").Return(vip.Get("MAIL_FROM_ADDRESS")) + config.On("GetString", "mail.from.name").Return(vip.Get("MAIL_FROM_NAME")) + config.On("GetString", "mail.username").Return(vip.Get("MAIL_USERNAME")) + config.On("GetString", "mail.password").Return(vip.Get("MAIL_PASSWORD")) + config.On("GetString", "mail.to").Return(vip.Get("MAIL_TO")) + config.On("GetString", "mail.cc").Return(vip.Get("MAIL_CC")) + config.On("GetString", "mail.bcc").Return(vip.Get("MAIL_BCC")) + config.EXPECT().GetString("mail.template.default", "html").Return("html").Once() + config.EXPECT().GetString("mail.template.engines.html.driver", "html").Return("html").Once() + config.EXPECT().GetString("mail.template.engines.html.path", "resources/views/mail"). + Return("resources/views/mail").Once() + + } + if os.Getenv("MAIL_HOST") != "" { + config.On("GetString", "mail.host").Return(os.Getenv("MAIL_HOST")) + config.On("GetInt", "mail.port").Return(mailPort) + config.On("GetString", "mail.from.address").Return(os.Getenv("MAIL_FROM_ADDRESS")) + config.On("GetString", "mail.from.name").Return(os.Getenv("MAIL_FROM_NAME")) + config.On("GetString", "mail.username").Return(os.Getenv("MAIL_USERNAME")) + config.On("GetString", "mail.password").Return(os.Getenv("MAIL_PASSWORD")) + config.On("GetString", "mail.to").Return(os.Getenv("MAIL_TO")) + config.On("GetString", "mail.cc").Return(os.Getenv("MAIL_CC")) + config.On("GetString", "mail.bcc").Return(os.Getenv("MAIL_BCC")) + config.EXPECT().GetString("mail.template.default", "html").Return("html").Once() + config.EXPECT().GetString("mail.template.engines.html.driver", "html").Return("html").Once() + config.EXPECT().GetString("mail.template.engines.html.path", "resources/views/mail"). + Return("resources/views/mail").Once() + } + + return config +} diff --git a/notification/channel_manager.go b/notification/channel_manager.go new file mode 100644 index 000000000..a53c4482d --- /dev/null +++ b/notification/channel_manager.go @@ -0,0 +1,40 @@ +package notification + +import ( + "github.com/goravel/framework/contracts/notification" + "github.com/goravel/framework/notification/channels" + "strings" + "sync" +) + +// channelRegistry manages registered channels in a thread-safe manner. +type channelRegistry struct { + mu sync.RWMutex + channels map[string]notification.Channel +} + +var registry = &channelRegistry{ + channels: make(map[string]notification.Channel), +} + +// RegisterChannel allows registering a custom channel, typically during application boot. +// Channel names are normalized to lowercase. +func RegisterChannel(name string, ch notification.Channel) { + registry.mu.Lock() + defer registry.mu.Unlock() + registry.channels[strings.ToLower(name)] = ch +} + +// GetChannel returns a previously registered channel by name. +func GetChannel(name string) (notification.Channel, bool) { + registry.mu.RLock() + defer registry.mu.RUnlock() + ch, ok := registry.channels[strings.ToLower(name)] + return ch, ok +} + +// RegisterDefaultChannels registers built-in default channels: mail and database. +func RegisterDefaultChannels() { + RegisterChannel("mail", &channels.MailChannel{}) + RegisterChannel("database", &channels.DatabaseChannel{}) +} diff --git a/notification/channels/database.go b/notification/channels/database.go new file mode 100644 index 000000000..bb24acf88 --- /dev/null +++ b/notification/channels/database.go @@ -0,0 +1,53 @@ +package channels + +import ( + "fmt" + "github.com/google/uuid" + contractsqueuedb "github.com/goravel/framework/contracts/database/db" + "github.com/goravel/framework/contracts/notification" + "github.com/goravel/framework/notification/models" + "github.com/goravel/framework/notification/utils" + "github.com/goravel/framework/support/json" + "github.com/goravel/framework/support/str" +) + +// DatabaseChannel is the default database persistence channel. +type DatabaseChannel struct { + db contractsqueuedb.DB +} + +// Send persists the notification payload to the notifications table. +// It expects the notification to implement a ToDatabase(notifiable) method or PayloadProvider. +func (c *DatabaseChannel) Send(notifiable notification.Notifiable, notif interface{}) error { + data, err := utils.CallToMethod(notif, "ToDatabase", notifiable) + if err != nil { + return fmt.Errorf("[DatabaseChannel] %s", err.Error()) + } + + var notificationModel models.Notification + notificationModel.ID = uuid.New().String() + if v, ok := notifiable.NotificationParams()["id"]; ok { + if s, ok := v.(string); ok { + notificationModel.NotifiableId = s + } + } + if notificationModel.NotifiableId == "" { + return fmt.Errorf("[DatabaseChannel] notifiable has no id") + } + + notificationModel.NotifiableType = str.Of(fmt.Sprintf("%T", notifiable)).Replace("*", "").String() + notificationModel.Type = str.Of(fmt.Sprintf("%T", notif)).Replace("*", "").String() + + jsonData, _ := json.MarshalString(data) + notificationModel.Data = jsonData + + if _, err = c.db.Table("notifications").Insert(¬ificationModel); err != nil { + return err + } + return nil +} + +// SetDB injects the database facade into the channel. +func (c *DatabaseChannel) SetDB(db contractsqueuedb.DB) { + c.db = db +} diff --git a/notification/channels/mail.go b/notification/channels/mail.go new file mode 100644 index 000000000..039b36eb5 --- /dev/null +++ b/notification/channels/mail.go @@ -0,0 +1,69 @@ +package channels + +import ( + "fmt" + contractsmail "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/notification" + "github.com/goravel/framework/mail" + "github.com/goravel/framework/notification/utils" +) + +// MailChannel is the default mail delivery channel. +type MailChannel struct { + mail contractsmail.Mail +} + +// Send delivers a notification via email using the notifiable's params. +// It expects the notification to implement a ToMail(notifiable) method or PayloadProvider. +func (c *MailChannel) Send(notifiable notification.Notifiable, notif interface{}) error { + data, err := utils.CallToMethod(notif, "ToMail", notifiable) + if err != nil { + return fmt.Errorf("[MailChannel] %s", err.Error()) + } + params := notifiable.NotificationParams() + email := getEmail(params) + if email == "" { + return fmt.Errorf("[MailChannel] notifiable has no mail") + } + + contentVal, ok := data["content"] + if !ok { + return fmt.Errorf("[MailChannel] content not provided") + } + subjectVal, ok := data["subject"] + if !ok { + return fmt.Errorf("[MailChannel] subject not provided") + } + content, _ := contentVal.(string) + subject, _ := subjectVal.(string) + if content == "" || subject == "" { + return fmt.Errorf("[MailChannel] invalid content or subject") + } + + if err := c.mail.To([]string{email}). + Content(mail.Html(content)). + Subject(subject).Send(); err != nil { + return err + } + + return nil +} + +// SetMail injects the mail facade into the channel. +func (c *MailChannel) SetMail(mail contractsmail.Mail) { + c.mail = mail +} + +func getEmail(params map[string]interface{}) string { + if v, ok := params["mail"]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + if v, ok := params["email"]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return "" +} diff --git a/notification/job.go b/notification/job.go new file mode 100644 index 000000000..8e8f7caa3 --- /dev/null +++ b/notification/job.go @@ -0,0 +1,107 @@ +package notification + +import ( + "bytes" + "encoding/gob" + "fmt" + "github.com/goravel/framework/contracts/config" + contractsqueuedb "github.com/goravel/framework/contracts/database/db" + contractsmail "github.com/goravel/framework/contracts/mail" + contractsnotification "github.com/goravel/framework/contracts/notification" + "github.com/goravel/framework/notification/channels" +) + +// SendNotificationJob is the queue job that delivers notifications to channels. +// It decodes the serialized payload and invokes the appropriate channel senders. +type SendNotificationJob struct { + config config.Config + db contractsqueuedb.DB + mail contractsmail.Mail +} + +// GobEnvelope wraps both notifiable and notification for simplified serialization. +type GobEnvelope struct { + Notifiable any + Notif any +} + +// NewSendNotificationJob constructs a SendNotificationJob with required facades. +func NewSendNotificationJob(config config.Config, db contractsqueuedb.DB, mail contractsmail.Mail) *SendNotificationJob { + return &SendNotificationJob{ + config: config, + db: db, + mail: mail, + } +} + +// Signature returns the unique name of the job. +func (r *SendNotificationJob) Signature() string { + return "goravel_send_notification_job" +} + +// Handle executes the job, decoding arguments and forwarding to registered channels. +// Expected arguments: +// 0: notifiable bytes (GobEnvelope when notif bytes empty) +// 1: notification bytes +// 2: []string of channel names +func (r *SendNotificationJob) Handle(args ...any) error { + if len(args) != 3 { + return fmt.Errorf("expected 3 arguments, got %d", len(args)) + } + + notifiableBytes, _ := args[0].([]uint8) + notifBytes, _ := args[1].([]uint8) + vias, ok := args[2].([]string) + if !ok { + return fmt.Errorf("invalid channels payload type: %T", args[2]) + } + + var notifiable any + var notif any + // Try envelope decode first + if len(notifiableBytes) > 0 && len(notifBytes) == 0 { + var env GobEnvelope + if err := gob.NewDecoder(bytes.NewReader(notifiableBytes)).Decode(&env); err != nil { + return err + } + notifiable = env.Notifiable + notif = env.Notif + } else { + dec1 := gob.NewDecoder(bytes.NewReader(notifiableBytes)) + if err := dec1.Decode(¬ifiable); err != nil { + return err + } + + dec2 := gob.NewDecoder(bytes.NewReader(notifBytes)) + if err := dec2.Decode(¬if); err != nil { + return err + } + } + + nbl, ok := notifiable.(contractsnotification.Notifiable) + if !ok { + return fmt.Errorf("decoded notifiable does not implement Notifiable: %T", notifiable) + } + nf, ok := notif.(contractsnotification.Notif) + if !ok { + return fmt.Errorf("decoded notif does not implement Notif: %T", notif) + } + + for _, chName := range vias { + ch, ok := GetChannel(chName) + if !ok { + return fmt.Errorf("channel not registered: %s", chName) + } + switch chTyped := ch.(type) { + case *channels.DatabaseChannel: + chTyped.SetDB(r.db) + case *channels.MailChannel: + chTyped.SetMail(r.mail) + } + if err := ch.Send(nbl, nf); err != nil { + return fmt.Errorf("channel %s send error: %w", chName, err) + } + } + + return nil +} diff --git a/notification/job_test.go b/notification/job_test.go new file mode 100644 index 000000000..24e641afd --- /dev/null +++ b/notification/job_test.go @@ -0,0 +1,53 @@ +package notification + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + mocksconfig "github.com/goravel/framework/mocks/config" +) + +type SendNotificationJobTestSuite struct { + suite.Suite + job *SendNotificationJob + mockConfig *mocksconfig.Config +} + +func TestSendNotificationJobTestSuite(t *testing.T) { + suite.Run(t, new(SendNotificationJobTestSuite)) +} + +func (r *SendNotificationJobTestSuite) SetupTest() { + r.mockConfig = mocksconfig.NewConfig(r.T()) + r.job = NewSendNotificationJob(r.mockConfig, nil, nil) + r.NotNil(r.job) + r.Equal(r.mockConfig, r.job.config) +} + +func (r *SendNotificationJobTestSuite) TestSignature() { + r.Equal("goravel_send_notification_job", r.job.Signature()) +} + +func (r *SendNotificationJobTestSuite) TestHandle_WrongArgumentCount() { + tests := []struct { + name string + args []any + }{ + { + name: "too few arguments", + args: []any{"subject", "html"}, + }, + } + + for _, test := range tests { + r.Run(test.name, func() { + err := r.job.Handle(test.args...) + r.Contains(err.Error(), "expected 3 arguments") + }) + } +} + +func (r *SendNotificationJobTestSuite) TestHandle_WrongArgumentTypes() { + +} diff --git a/notification/models/notification.go b/notification/models/notification.go new file mode 100644 index 000000000..3b8dc5471 --- /dev/null +++ b/notification/models/notification.go @@ -0,0 +1,18 @@ +package models + +import ( + "github.com/goravel/framework/database/orm" + "github.com/goravel/framework/support/carbon" +) + +// Notification represents a stored notification record. +// Fields align with the `notifications` table schema. +type Notification struct { + ID string `json:"id"` + Type string `json:"type"` + NotifiableType string `json:"notifiable_type"` + NotifiableId string `json:"notifiable_id"` + Data string `json:"data"` + ReadAt *carbon.DateTime `json:"read_at"` + orm.Timestamps +} diff --git a/notification/notification_sender.go b/notification/notification_sender.go new file mode 100644 index 000000000..9c6c6a148 --- /dev/null +++ b/notification/notification_sender.go @@ -0,0 +1,95 @@ +package notification + +import ( + "bytes" + "encoding/gob" + "fmt" + contractsqueuedb "github.com/goravel/framework/contracts/database/db" + contractsmail "github.com/goravel/framework/contracts/mail" + "github.com/goravel/framework/contracts/notification" + contractsqueue "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/errors" + "github.com/goravel/framework/notification/channels" +) + +// NotificationSender coordinates sending notifications via registered channels. +// It supports both immediate dispatch (SendNow) and queued dispatch (Send). +type NotificationSender struct { + db contractsqueuedb.DB + mail contractsmail.Mail + queue contractsqueue.Queue +} + +// NewNotificationSender creates a new NotificationSender with the provided facades. +func NewNotificationSender(db contractsqueuedb.DB, mail contractsmail.Mail, queue contractsqueue.Queue) *NotificationSender { + return &NotificationSender{ + db: db, + mail: mail, + queue: queue, + } +} + +// Send enqueues notifications for asynchronous processing. +// For each notifiable, the notification payload is serialized and a job is dispatched. +func (s *NotificationSender) Send(notifiables []notification.Notifiable, notification notification.Notif) error { + return s.queueNotification(notifiables, notification) +} + +// SendNow sends notifications immediately without queuing. +// Channels are resolved per notifiable using Notif.Via and configured with facades. +func (s *NotificationSender) SendNow(notifiables []notification.Notifiable, notif notification.Notif) error { + for _, notifiable := range notifiables { + vias := notif.Via(notifiable) + if len(vias) == 0 { + return errors.New("no channels defined for notification") + } + + for _, chName := range vias { + ch, ok := GetChannel(chName) + if !ok { + return fmt.Errorf("channel not registered: %s", chName) + } + switch chTyped := ch.(type) { + case *channels.DatabaseChannel: + chTyped.SetDB(s.db) + case *channels.MailChannel: + chTyped.SetMail(s.mail) + } + if err := ch.Send(notifiable, notif); err != nil { + return fmt.Errorf("channel %s send error: %w", chName, err) + } + } + } + return nil +} + +// queueNotification serializes the notifiable and notification, then dispatches +// a SendNotificationJob with the target channels for asynchronous processing. +func (s *NotificationSender) queueNotification(notifiables []notification.Notifiable, notif notification.Notif) error { + for _, notifiable := range notifiables { + vias := notif.Via(notifiable) + if len(vias) == 0 { + return errors.New("no channels defined for notification") + } + + gob.Register(notifiable) + gob.Register(notif) + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + if err := enc.Encode(GobEnvelope{Notifiable: notifiable, Notif: notif}); err != nil { + return err + } + + args := []contractsqueue.Arg{ + {Type: "[]uint8", Value: buf.Bytes()}, + {Type: "[]uint8", Value: []byte{}}, + {Type: "[]string", Value: vias}, + } + + pendingJob := s.queue.Job(NewSendNotificationJob(nil, s.db, s.mail), args) + if err := pendingJob.Dispatch(); err != nil { + return err + } + } + return nil +} diff --git a/notification/service_provider.go b/notification/service_provider.go new file mode 100644 index 000000000..70d113784 --- /dev/null +++ b/notification/service_provider.go @@ -0,0 +1,82 @@ +package notification + +import ( + "github.com/goravel/framework/contracts/binding" + "github.com/goravel/framework/contracts/foundation" + contractsqueue "github.com/goravel/framework/contracts/queue" + "github.com/goravel/framework/errors" +) + +type ServiceProvider struct { +} + +// Relationship declares bindings and dependencies for the notification service provider. +func (r *ServiceProvider) Relationship() binding.Relationship { + return binding.Relationship{ + Bindings: []string{ + binding.Notification, + }, + Dependencies: binding.Bindings[binding.Notification].Dependencies, + ProvideFor: []string{}, + } +} + +// Register binds the notification Application into the container using required facades. +func (r *ServiceProvider) Register(app foundation.Application) { + app.Bind(binding.Notification, func(app foundation.Application) (any, error) { + config := app.MakeConfig() + if config == nil { + return nil, errors.ConfigFacadeNotSet.SetModule(errors.ModuleMail) + } + + queue := app.MakeQueue() + if queue == nil { + return nil, errors.QueueFacadeNotSet.SetModule(errors.ModuleQueue) + } + + mail := app.MakeMail() + if mail == nil { + return nil, errors.New("Mail facade not set") + } + + db := app.MakeDB() + if db == nil { + return nil, errors.DBFacadeNotSet.SetModule(errors.ModuleDB) + } + + return NewApplication(config, queue, db, mail) + }) +} + +// Boot initializes built-in channels and registers jobs once all providers are registered. +func (r *ServiceProvider) Boot(app foundation.Application) { + RegisterDefaultChannels() + r.registerJobs(app) +} + +// registerJobs registers the SendNotificationJob with the queue facade if available. +func (r *ServiceProvider) registerJobs(app foundation.Application) { + queueFacade := app.MakeQueue() + if queueFacade == nil { + return + } + + configFacade := app.MakeConfig() + if configFacade == nil { + return + } + + mailFacade := app.MakeMail() + if mailFacade == nil { + return + } + + dbFacade := app.MakeDB() + if dbFacade == nil { + return + } + + queueFacade.Register([]contractsqueue.Job{ + NewSendNotificationJob(configFacade, dbFacade, mailFacade), + }) +} diff --git a/notification/utils/notification_method_caller.go b/notification/utils/notification_method_caller.go new file mode 100644 index 000000000..44c0bedaa --- /dev/null +++ b/notification/utils/notification_method_caller.go @@ -0,0 +1,74 @@ +package utils + +import ( + "fmt" + contractsnotification "github.com/goravel/framework/contracts/notification" + "reflect" + "strings" +) + +// CallToMethod invokes a notification's channel-specific method via reflection. +// It supports both value and pointer receivers, and falls back to PayloadProvider. +// The returned value is normalized as map[string]interface{} for channel consumption. +func CallToMethod(notification interface{}, methodName string, notifiable contractsnotification.Notifiable) (map[string]interface{}, error) { + v := reflect.ValueOf(notification) + if !v.IsValid() { + return nil, fmt.Errorf("invalid notification value") + } + + // Locate method, preferring pointer receiver if available. + method := v.MethodByName(methodName) + if !method.IsValid() && v.CanAddr() { + method = v.Addr().MethodByName(methodName) + } + if !method.IsValid() { + // Fallback: support PayloadProvider for dynamic channel payloads. + if provider, ok := v.Interface().(contractsnotification.PayloadProvider); ok { + channel := strings.ToLower(strings.TrimPrefix(methodName, "To")) + return provider.PayloadFor(channel, notifiable) + } + return nil, fmt.Errorf("method %s not found", methodName) + } + + // Invoke method with the notifiable. + results := method.Call([]reflect.Value{reflect.ValueOf(notifiable)}) + if len(results) == 0 { + return nil, fmt.Errorf("method %s returned no values", methodName) + } + + // Handle optional error return value. + if len(results) >= 2 && !results[1].IsNil() { + if err, ok := results[1].Interface().(error); ok { + return nil, err + } + return nil, fmt.Errorf("second return of %s is not error", methodName) + } + + // Convert the first return value to map[string]interface{}. + first := results[0].Interface() + switch data := first.(type) { + case map[string]interface{}: + return data, nil + case map[string]string: + out := make(map[string]interface{}, len(data)) + for k, v := range data { + out[k] = v + } + return out, nil + } + + // Handle struct result by exporting fields. + if rv := reflect.ValueOf(first); rv.Kind() == reflect.Struct { + out := make(map[string]interface{}) + rt := rv.Type() + for i := 0; i < rv.NumField(); i++ { + field := rt.Field(i) + if field.PkgPath == "" { // only exported fields + out[field.Name] = rv.Field(i).Interface() + } + } + return out, nil + } + + return nil, fmt.Errorf("unsupported return type from %s", methodName) +}