diff --git a/container/container.go b/container/container.go new file mode 100644 index 0000000..b7a4cc9 --- /dev/null +++ b/container/container.go @@ -0,0 +1,118 @@ +package container + +import ( + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/nbtca/saturday/repo" + "github.com/nbtca/saturday/service" + "github.com/spf13/viper" +) + +// Container holds all application dependencies +type Container struct { + // Database + db *sqlx.DB + sq squirrel.StatementBuilderType + + // Repositories + memberRepo repo.MemberRepository + eventRepo repo.EventRepository + clientRepo repo.ClientRepository + roleRepo repo.RoleRepository + dbManager repo.DatabaseManager + + // Services + memberService service.MemberServiceInterface + logtoService service.LogtoServiceInterface + eventService service.EventServiceInterface + clientService service.ClientServiceInterface +} + +// NewContainer creates and initializes a new dependency injection container +func NewContainer() *Container { + container := &Container{} + container.initializeRepositories() + container.initializeServices() + return container +} + +// initializeRepositories sets up all repository instances +func (c *Container) initializeRepositories() { + // Get database connection from global state (for now, during migration) + c.db = repo.GetDB() // We'll need to add this method to implementations.go + c.sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar) + + // Initialize repositories + c.memberRepo = repo.NewMemberRepository(c.db, c.sq) + c.eventRepo = repo.NewEventRepository(c.db, c.sq) + c.clientRepo = repo.NewClientRepository(c.db, c.sq) + c.roleRepo = repo.NewRoleRepository(c.db, c.sq) + c.dbManager = repo.NewDatabaseManager() +} + +// initializeServices sets up all service instances with their dependencies +func (c *Container) initializeServices() { + // Initialize Logto service + logtoEndpoint := viper.GetString("logto.endpoint") + c.logtoService = service.NewLogtoService(logtoEndpoint) + + // Initialize member service with dependencies + c.memberService = service.NewMemberService( + c.memberRepo, + c.roleRepo, + c.logtoService, + ) + + // TODO: Initialize other services + // c.eventService = service.NewEventService(c.eventRepo, c.memberService) + // c.clientService = service.NewClientService(c.clientRepo) +} + +// Getter methods for services + +// MemberService returns the member service instance +func (c *Container) MemberService() service.MemberServiceInterface { + return c.memberService +} + +// LogtoService returns the logto service instance +func (c *Container) LogtoService() service.LogtoServiceInterface { + return c.logtoService +} + +// EventService returns the event service instance +func (c *Container) EventService() service.EventServiceInterface { + return c.eventService +} + +// ClientService returns the client service instance +func (c *Container) ClientService() service.ClientServiceInterface { + return c.clientService +} + +// Getter methods for repositories (for testing) + +// MemberRepository returns the member repository instance +func (c *Container) MemberRepository() repo.MemberRepository { + return c.memberRepo +} + +// EventRepository returns the event repository instance +func (c *Container) EventRepository() repo.EventRepository { + return c.eventRepo +} + +// ClientRepository returns the client repository instance +func (c *Container) ClientRepository() repo.ClientRepository { + return c.clientRepo +} + +// RoleRepository returns the role repository instance +func (c *Container) RoleRepository() repo.RoleRepository { + return c.roleRepo +} + +// DatabaseManager returns the database manager instance +func (c *Container) DatabaseManager() repo.DatabaseManager { + return c.dbManager +} \ No newline at end of file diff --git a/container/container_test.go b/container/container_test.go new file mode 100644 index 0000000..4ec6637 --- /dev/null +++ b/container/container_test.go @@ -0,0 +1,75 @@ +package container + +import ( + "testing" + + "github.com/nbtca/saturday/repo" + "github.com/nbtca/saturday/service" +) + +func TestNewContainer(t *testing.T) { + // This test requires database initialization + // For now, we'll skip if database is not available + + // Initialize database for testing + repo.InitDB() + defer repo.CloseDB() + + container := NewContainer() + + // Test that all services are properly initialized + if container.MemberService() == nil { + t.Error("MemberService should not be nil") + } + + if container.LogtoService() == nil { + t.Error("LogtoService should not be nil") + } + + // Test that all repositories are properly initialized + if container.MemberRepository() == nil { + t.Error("MemberRepository should not be nil") + } + + if container.RoleRepository() == nil { + t.Error("RoleRepository should not be nil") + } +} + +func TestContainerServiceTypes(t *testing.T) { + // Initialize database for testing + repo.InitDB() + defer repo.CloseDB() + + container := NewContainer() + + // Test that services implement the correct interfaces + _, ok := container.MemberService().(service.MemberServiceInterface) + if !ok { + t.Error("MemberService should implement MemberServiceInterface") + } + + _, ok = container.LogtoService().(service.LogtoServiceInterface) + if !ok { + t.Error("LogtoService should implement LogtoServiceInterface") + } +} + +func TestContainerRepositoryTypes(t *testing.T) { + // Initialize database for testing + repo.InitDB() + defer repo.CloseDB() + + container := NewContainer() + + // Test that repositories implement the correct interfaces + _, ok := container.MemberRepository().(repo.MemberRepository) + if !ok { + t.Error("MemberRepository should implement MemberRepository interface") + } + + _, ok = container.RoleRepository().(repo.RoleRepository) + if !ok { + t.Error("RoleRepository should implement RoleRepository interface") + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index 85ee8d4..c8c9467 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,9 @@ require ( github.com/MicahParks/keyfunc v1.9.0 github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible github.com/danielgtaylor/huma/v2 v2.28.0 - github.com/gin-contrib/cors v1.6.0 github.com/gin-gonic/gin v1.10.0 + github.com/go-chi/chi/v5 v5.2.2 + github.com/go-chi/cors v1.2.1 github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/webhooks/v6 v6.4.0 github.com/go-sql-driver/mysql v1.7.1 @@ -51,8 +52,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.5 // indirect - github.com/go-chi/chi/v5 v5.2.2 // indirect - github.com/go-chi/cors v1.2.1 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect @@ -121,6 +120,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/grpc v1.67.3 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.4.0 // indirect ) diff --git a/go.sum b/go.sum index 14f9b8e..5118dc5 100644 --- a/go.sum +++ b/go.sum @@ -103,8 +103,6 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= -github.com/gin-contrib/cors v1.6.0 h1:0Z7D/bVhE6ja07lI8CTjTonp6SB07o8bNuFyRbsBUQg= -github.com/gin-contrib/cors v1.6.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= @@ -267,6 +265,7 @@ github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgSh github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/main.go b/main.go index 74b76b3..209487d 100644 --- a/main.go +++ b/main.go @@ -7,9 +7,9 @@ import ( "strings" "time" + "github.com/nbtca/saturday/container" "github.com/nbtca/saturday/repo" "github.com/nbtca/saturday/router" - "github.com/nbtca/saturday/service" "github.com/nbtca/saturday/util" "github.com/joho/godotenv" @@ -63,10 +63,11 @@ func main() { repo.InitDB() defer repo.CloseDB() - service.LogtoServiceApp = service.MakeLogtoService(viper.GetString("logto.endpoint")) - util.Logger.Debug("LogtoService initialized with endpoint: " + viper.GetString("logto.endpoint")) + // Initialize dependency injection container + container := container.NewContainer() + util.Logger.Debug("Dependency injection container initialized") - r := router.SetupRouter() + r := router.SetupRouter(container) viper.SetDefault("port", 4000) port := viper.GetInt("port") diff --git a/repo/implementations.go b/repo/implementations.go new file mode 100644 index 0000000..9b08fe9 --- /dev/null +++ b/repo/implementations.go @@ -0,0 +1,198 @@ +package repo + +import ( + "database/sql" + + "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/nbtca/saturday/model" +) + +// Concrete implementations of repository interfaces + +// memberRepository implements MemberRepository interface +type memberRepository struct { + db *sqlx.DB + sq squirrel.StatementBuilderType +} + +// NewMemberRepository creates a new member repository instance +func NewMemberRepository(db *sqlx.DB, sq squirrel.StatementBuilderType) MemberRepository { + return &memberRepository{db: db, sq: sq} +} + +func (r *memberRepository) GetMemberById(id string) (model.Member, error) { + return GetMemberById(id) +} + +func (r *memberRepository) GetMemberByLogtoId(logtoId string) (model.Member, error) { + return GetMemberByLogtoId(logtoId) +} + +func (r *memberRepository) GetMemberByGithubId(githubId string) (model.Member, error) { + return GetMemberByGithubId(githubId) +} + +func (r *memberRepository) GetMemberIdByLogtoId(logtoId string) (sql.NullString, error) { + return GetMemberIdByLogtoId(logtoId) +} + +func (r *memberRepository) GetMembers(offset uint64, limit uint64) ([]model.Member, error) { + return GetMembers(offset, limit) +} + +func (r *memberRepository) CreateMember(member *model.Member) error { + return CreateMember(member) +} + +func (r *memberRepository) UpdateMember(member model.Member) error { + return UpdateMember(member) +} + +func (r *memberRepository) ExistMember(id string) (bool, error) { + return ExistMember(id) +} + +// eventRepository implements EventRepository interface +type eventRepository struct { + db *sqlx.DB + sq squirrel.StatementBuilderType +} + +// NewEventRepository creates a new event repository instance +func NewEventRepository(db *sqlx.DB, sq squirrel.StatementBuilderType) EventRepository { + return &eventRepository{db: db, sq: sq} +} + +func (r *eventRepository) GetEventById(id int64) (model.Event, error) { + return GetEventById(id) +} + +func (r *eventRepository) GetEventByIssueId(issueId int64) (model.Event, error) { + return GetEventByIssueId(issueId) +} + +func (r *eventRepository) CreateEvent(event *model.Event) error { + return CreateEvent(event) +} + +func (r *eventRepository) UpdateEvent(event *model.Event, eventLog *model.EventLog) error { + return UpdateEvent(event, eventLog) +} + +func (r *eventRepository) UpdateEventSize(eventId int64, size string) error { + return UpdateEventSize(eventId, size) +} + +func (r *eventRepository) GetEvents(f EventFilter) ([]model.Event, error) { + return GetEvents(f) +} + +func (r *eventRepository) GetMemberEvents(f EventFilter, memberId string) ([]model.Event, error) { + return GetMemberEvents(f, memberId) +} + +func (r *eventRepository) GetClientEvents(f EventFilter, clientId int64) ([]model.Event, error) { + return GetClientEvents(f, clientId) +} + +func (r *eventRepository) GetClosedEventsByTimeRange(f EventFilter, startTime, endTime string) ([]JoinEvent, error) { + return GetClosedEventsByTimeRange(f, startTime, endTime) +} + +func (r *eventRepository) GetEventClientId(eventId int64) (int64, error) { + return GetEventClientId(eventId) +} + +func (r *eventRepository) CreateEventLog(eventLog *model.EventLog, conn *sqlx.Tx) error { + return CreateEventLog(eventLog, conn) +} + +func (r *eventRepository) ExistEventAction(action string) (bool, error) { + return ExistEventAction(action) +} + +func (r *eventRepository) SetEventAction(eventLogId int64, action string, conn *sqlx.Tx) error { + return SetEventAction(eventLogId, action, conn) +} + +func (r *eventRepository) ExistEventStatus(status string) (bool, error) { + return ExistEventStatus(status) +} + +func (r *eventRepository) SetEventStatus(eventId int64, status string, conn *sqlx.Tx) (sql.Result, error) { + return SetEventStatus(eventId, status, conn) +} + +// clientRepository implements ClientRepository interface +type clientRepository struct { + db *sqlx.DB + sq squirrel.StatementBuilderType +} + +// NewClientRepository creates a new client repository instance +func NewClientRepository(db *sqlx.DB, sq squirrel.StatementBuilderType) ClientRepository { + return &clientRepository{db: db, sq: sq} +} + +func (r *clientRepository) GetClientByOpenId(openId string) (model.Client, error) { + return GetClientByOpenId(openId) +} + +func (r *clientRepository) GetClientByLogtoId(logtoId string) (model.Client, error) { + return GetClientByLogtoId(logtoId) +} + +func (r *clientRepository) CreateClient(client *model.Client) error { + return CreateClient(client) +} + +// roleRepository implements RoleRepository interface +type roleRepository struct { + db *sqlx.DB + sq squirrel.StatementBuilderType +} + +// NewRoleRepository creates a new role repository instance +func NewRoleRepository(db *sqlx.DB, sq squirrel.StatementBuilderType) RoleRepository { + return &roleRepository{db: db, sq: sq} +} + +func (r *roleRepository) ExistRole(role string) (bool, error) { + return ExistRole(role) +} + +func (r *roleRepository) SetMemberRole(memberId string, role string, conn *sqlx.Tx) error { + return SetMemberRole(memberId, role, conn) +} + +// databaseManager implements DatabaseManager interface +type databaseManager struct{} + +// NewDatabaseManager creates a new database manager instance +func NewDatabaseManager() DatabaseManager { + return &databaseManager{} +} + +func (dm *databaseManager) InitDB() error { + InitDB() + return nil +} + +func (dm *databaseManager) CloseDB() error { + CloseDB() + return nil +} + +func (dm *databaseManager) SetDB(dbx *sqlx.DB) { + SetDB(dbx) +} + +func (dm *databaseManager) GetDB() *sqlx.DB { + return db +} + +// GetDB returns the global database connection (used during DI migration) +func GetDB() *sqlx.DB { + return db +} \ No newline at end of file diff --git a/repo/interfaces.go b/repo/interfaces.go new file mode 100644 index 0000000..ed94371 --- /dev/null +++ b/repo/interfaces.go @@ -0,0 +1,72 @@ +package repo + +import ( + "database/sql" + + "github.com/jmoiron/sqlx" + "github.com/nbtca/saturday/model" +) + +// MemberRepository defines the interface for member-related database operations +type MemberRepository interface { + // Member retrieval + GetMemberById(id string) (model.Member, error) + GetMemberByLogtoId(logtoId string) (model.Member, error) + GetMemberByGithubId(githubId string) (model.Member, error) + GetMemberIdByLogtoId(logtoId string) (sql.NullString, error) + GetMembers(offset uint64, limit uint64) ([]model.Member, error) + + // Member management + CreateMember(member *model.Member) error + UpdateMember(member model.Member) error + ExistMember(id string) (bool, error) +} + +// EventRepository defines the interface for event-related database operations +type EventRepository interface { + // Core event operations + GetEventById(id int64) (model.Event, error) + GetEventByIssueId(issueId int64) (model.Event, error) + CreateEvent(event *model.Event) error + UpdateEvent(event *model.Event, eventLog *model.EventLog) error + UpdateEventSize(eventId int64, size string) error + + // Event filtering and retrieval + GetEvents(f EventFilter) ([]model.Event, error) + GetMemberEvents(f EventFilter, memberId string) ([]model.Event, error) + GetClientEvents(f EventFilter, clientId int64) ([]model.Event, error) + GetClosedEventsByTimeRange(f EventFilter, startTime, endTime string) ([]JoinEvent, error) + + // Event utility functions + GetEventClientId(eventId int64) (int64, error) + + // Event log operations + CreateEventLog(eventLog *model.EventLog, conn *sqlx.Tx) error + + // Event status/action management + ExistEventAction(action string) (bool, error) + SetEventAction(eventLogId int64, action string, conn *sqlx.Tx) error + ExistEventStatus(status string) (bool, error) + SetEventStatus(eventId int64, status string, conn *sqlx.Tx) (sql.Result, error) +} + +// ClientRepository defines the interface for client-related database operations +type ClientRepository interface { + GetClientByOpenId(openId string) (model.Client, error) + GetClientByLogtoId(logtoId string) (model.Client, error) + CreateClient(client *model.Client) error +} + +// RoleRepository defines the interface for role-related database operations +type RoleRepository interface { + ExistRole(role string) (bool, error) + SetMemberRole(memberId string, role string, conn *sqlx.Tx) error +} + +// DatabaseManager defines the interface for database lifecycle management +type DatabaseManager interface { + InitDB() error + CloseDB() error + SetDB(dbx *sqlx.DB) + GetDB() *sqlx.DB +} \ No newline at end of file diff --git a/router/main.go b/router/main.go index 1219e70..fd13385 100644 --- a/router/main.go +++ b/router/main.go @@ -10,6 +10,7 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/go-chi/chi/v5" "github.com/go-chi/cors" + "github.com/nbtca/saturday/container" "github.com/nbtca/saturday/middleware" "github.com/nbtca/saturday/service" "github.com/nbtca/saturday/util" @@ -20,7 +21,7 @@ type PingResponse struct { Pong string `json:"message" example:"ping" doc:"Ping message"` } -func SetupRouter() *chi.Mux { +func SetupRouter(container *container.Container) *chi.Mux { // Create Chi router router := chi.NewRouter() @@ -56,6 +57,15 @@ func SetupRouter() *chi.Mux { api.UseMiddleware(middleware.HumaLogger()) api.UseMiddleware(middleware.HumaAuthMiddleware) + // Initialize routers with dependency injection + memberRouter := NewMemberRouter(container.MemberService()) + + // TODO: Initialize other routers with container dependencies + // For now, use global router instances to maintain compatibility + // These will be converted to DI in future iterations + // eventRouter := NewEventRouter(container.EventService()) + // clientRouter := NewClientRouter(container.ClientService()) + // Keep webhooks as raw endpoints since they don't need OpenAPI documentation hook, _ := service.MakeGithubWebHook(viper.GetString("github.webhook_secret")) router.Post("/webhook", func(w http.ResponseWriter, r *http.Request) { @@ -97,7 +107,7 @@ func SetupRouter() *chi.Mux { Path: "/members/{MemberId}", Summary: "Get a public member by id", Tags: []string{"Member", "Public"}, - }, MemberRouterApp.GetPublicMemberById) + }, memberRouter.GetPublicMemberById) huma.Register(api, huma.Operation{ OperationID: "get-public-member-by-page", @@ -105,7 +115,7 @@ func SetupRouter() *chi.Mux { Path: "/members", Summary: "Get a public member by page", Tags: []string{"Member", "Public"}, - }, MemberRouterApp.GetPublicMemberByPage) + }, memberRouter.GetPublicMemberByPage) huma.Register(api, huma.Operation{ OperationID: "create-token", @@ -113,7 +123,7 @@ func SetupRouter() *chi.Mux { Path: "/members/{MemberId}/token", Summary: "Create token", Tags: []string{"Member", "Public"}, - }, MemberRouterApp.CreateToken) + }, memberRouter.CreateToken) huma.Register(api, huma.Operation{ OperationID: "create-token-via-logto-token", @@ -121,7 +131,7 @@ func SetupRouter() *chi.Mux { Path: "/member/token/logto", Summary: "Create token via logto token", Tags: []string{"Member", "Public"}, - }, MemberRouterApp.CreateTokenViaLogtoToken) + }, memberRouter.CreateTokenViaLogtoToken) huma.Register(api, huma.Operation{ OperationID: "bind-member-logto-id", @@ -129,7 +139,7 @@ func SetupRouter() *chi.Mux { Path: "/members/{MemberId}/logto_id", Summary: "Bind member logto id", Tags: []string{"Member", "Public"}, - }, MemberRouterApp.BindMemberLogtoId) + }, memberRouter.BindMemberLogtoId) huma.Register(api, huma.Operation{ OperationID: "create-token-via-wechat", @@ -211,7 +221,7 @@ func SetupRouter() *chi.Mux { Path: "/member/activate", Summary: "Activate member", Tags: []string{"Member", "Private"}, - }, MemberRouterApp.Activate) + }, memberRouter.Activate) huma.Register(api, huma.Operation{ OperationID: "get-member", @@ -219,7 +229,7 @@ func SetupRouter() *chi.Mux { Path: "/member", Summary: "Get current member", Tags: []string{"Member", "Private"}, - }, MemberRouterApp.GetMemberById) + }, memberRouter.GetMemberById) huma.Register(api, huma.Operation{ OperationID: "update-member", @@ -227,7 +237,7 @@ func SetupRouter() *chi.Mux { Path: "/member", Summary: "Update member", Tags: []string{"Member", "Private"}, - }, MemberRouterApp.Update) + }, memberRouter.Update) huma.Register(api, huma.Operation{ OperationID: "update-member-avatar", @@ -235,7 +245,7 @@ func SetupRouter() *chi.Mux { Path: "/member/avatar", Summary: "Update member avatar", Tags: []string{"Member", "Private"}, - }, MemberRouterApp.UpdateAvatar) + }, memberRouter.UpdateAvatar) huma.Register(api, huma.Operation{ OperationID: "get-member-events", @@ -292,7 +302,7 @@ func SetupRouter() *chi.Mux { Path: "/members", Summary: "Create multiple members", Tags: []string{"Member", "Admin"}, - }, MemberRouterApp.CreateMany) + }, memberRouter.CreateMany) huma.Register(api, huma.Operation{ OperationID: "create-member", @@ -300,7 +310,7 @@ func SetupRouter() *chi.Mux { Path: "/members/{MemberId}", Summary: "Create member", Tags: []string{"Member", "Admin"}, - }, MemberRouterApp.Create) + }, memberRouter.Create) huma.Register(api, huma.Operation{ OperationID: "get-members-full", @@ -308,7 +318,7 @@ func SetupRouter() *chi.Mux { Path: "/members/full", Summary: "Get members with full details", Tags: []string{"Member", "Admin"}, - }, MemberRouterApp.GetMemberByPage) + }, memberRouter.GetMemberByPage) huma.Register(api, huma.Operation{ OperationID: "update-member-basic", @@ -316,7 +326,7 @@ func SetupRouter() *chi.Mux { Path: "/members/{MemberId}", Summary: "Update member basic info", Tags: []string{"Member", "Admin"}, - }, MemberRouterApp.UpdateBasic) + }, memberRouter.UpdateBasic) huma.Register(api, huma.Operation{ OperationID: "export-events-xlsx", diff --git a/router/member.go b/router/member.go index a3a6f6b..0212099 100644 --- a/router/member.go +++ b/router/member.go @@ -2,78 +2,85 @@ package router import ( "context" - "log" "net/http" "github.com/danielgtaylor/huma/v2" "github.com/nbtca/saturday/middleware" "github.com/nbtca/saturday/model" "github.com/nbtca/saturday/model/dto" - "github.com/nbtca/saturday/repo" "github.com/nbtca/saturday/service" "github.com/nbtca/saturday/util" ) -type MemberRouter struct{} +type MemberRouter struct { + memberService service.MemberServiceInterface +} + +// NewMemberRouter creates a new MemberRouter with injected dependencies +func NewMemberRouter(memberService service.MemberServiceInterface) *MemberRouter { + return &MemberRouter{ + memberService: memberService, + } +} -func (MemberRouter) GetPublicMemberById(ctx context.Context, input *struct { +func (r *MemberRouter) GetPublicMemberById(ctx context.Context, input *struct { MemberId string `path:"MemberId" maxLength:"10" example:"2333333333" doc:"Name to greet"` }) (*util.CommonResponse[model.PublicMember], error) { - member, err := service.MemberServiceApp.GetPublicMemberById(input.MemberId) + member, err := r.memberService.GetPublicMemberById(input.MemberId) if err != nil { return nil, huma.NewError(http.StatusUnprocessableEntity, err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) GetPublicMemberByPage(ctx context.Context, input *struct { +func (r *MemberRouter) GetPublicMemberByPage(ctx context.Context, input *struct { dto.PageRequest }) (*util.CommonResponse[[]model.PublicMember], error) { - members, err := service.MemberServiceApp.GetPublicMembers(input.Offset, input.Limit) + members, err := r.memberService.GetPublicMembers(input.Offset, input.Limit) if err != nil { return nil, huma.NewError(http.StatusUnprocessableEntity, err.Error()) } return util.MakeCommonResponse(members), nil } -func (MemberRouter) GetMemberByPage(ctx context.Context, input *GetMemberByPageInput) (*util.CommonResponse[[]model.Member], error) { +func (r *MemberRouter) GetMemberByPage(ctx context.Context, input *GetMemberByPageInput) (*util.CommonResponse[[]model.Member], error) { _, err := middleware.AuthenticateUser(input.Authorization, "admin") if err != nil { return nil, err } - members, err := service.MemberServiceApp.GetMembers(input.Offset, input.Limit) + members, err := r.memberService.GetMembers(input.Offset, input.Limit) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(members), nil } -func (MemberRouter) GetMemberById(ctx context.Context, input *GetMemberInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) GetMemberById(ctx context.Context, input *GetMemberInput) (*util.CommonResponse[model.Member], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "member", "admin") if err != nil { return nil, err } - member, err := service.MemberServiceApp.GetMemberById(auth.ID) + member, err := r.memberService.GetMemberById(auth.ID) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) CreateToken(ctx context.Context, input *struct { +func (r *MemberRouter) CreateToken(ctx context.Context, input *struct { MemberId string `path:"MemberId" maxLength:"10" example:"2333333333" doc:"Member Id"` Body struct { Password string `json:"password"` } }) (*util.CommonResponse[dto.CreateMemberTokenResponse], error) { - member, err := service.MemberServiceApp.GetMemberById(input.MemberId) + member, err := r.memberService.GetMemberById(input.MemberId) if err != nil { return nil, huma.NewError(http.StatusUnprocessableEntity, err.Error()) } if member.Password != input.Body.Password { return nil, huma.NewError(http.StatusUnprocessableEntity, "Invalid password") } - token, err := service.MemberServiceApp.CreateToken(member) + token, err := r.memberService.CreateToken(member) if err != nil { return nil, huma.NewError(http.StatusUnprocessableEntity, err.Error()) } @@ -83,64 +90,21 @@ func (MemberRouter) CreateToken(ctx context.Context, input *struct { }), nil } -func (MemberRouter) CreateTokenViaLogtoToken(c context.Context, input *struct { +func (r *MemberRouter) CreateTokenViaLogtoToken(c context.Context, input *struct { Authorization string `header:"Authorization"` }) (*util.CommonResponse[dto.CreateMemberTokenResponse], error) { - user, err := service.LogtoServiceApp.FetchUserByToken(input.Authorization) - if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) - } - if user.Id == "" { - return nil, huma.Error422UnprocessableEntity("Invalid token: id missing") - } - memberId, err := repo.GetMemberIdByLogtoId(user.Id) - if err != nil || !memberId.Valid { - return nil, huma.Error422UnprocessableEntity("Invalid token: member not found") - } - - member, err := service.MemberServiceApp.GetMemberById(memberId.String) - if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) - } - logto_roles, err := service.LogtoServiceApp.FetchUserRole(user.Id) - if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) - } - - mappedRole := service.MemberServiceApp.MapLogtoUserRole(logto_roles) - if mappedRole != member.Role && mappedRole != "" { - member.Role = mappedRole - err = service.MemberServiceApp.UpdateMember(member) - if err != nil { - return nil, huma.Error422UnprocessableEntity("error at syncing member role" + err.Error()) - } - } - - t, err := service.MemberServiceApp.CreateToken(member) + member, token, err := r.memberService.AuthenticateAndSyncMember(input.Authorization) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } - - patchLogtoUserRequest := dto.PatchLogtoUserRequest{} - - if member.Alias != "" && user.Name == "" { - patchLogtoUserRequest.Name = member.Alias - } - if member.Avatar != "" && user.Avatar == "" { - patchLogtoUserRequest.Avatar = member.Avatar - } - - _, err = service.LogtoServiceApp.PatchUserById(user.Id, patchLogtoUserRequest) - if err != nil { - log.Println(err) - } + return util.MakeCommonResponse(dto.CreateMemberTokenResponse{ Member: member, - Token: t, + Token: token, }), nil } -func (MemberRouter) Create(ctx context.Context, input *CreateMemberInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) Create(ctx context.Context, input *CreateMemberInput) (*util.CommonResponse[model.Member], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "admin") if err != nil { return nil, err @@ -157,14 +121,14 @@ func (MemberRouter) Create(ctx context.Context, input *CreateMemberInput) (*util Role: input.Body.Role, CreatedBy: auth.ID, } - err = service.MemberServiceApp.CreateMember(member) + err = r.memberService.CreateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(*member), nil } -func (MemberRouter) CreateWithLogto(c context.Context, input *struct { +func (r *MemberRouter) CreateWithLogto(c context.Context, input *struct { MemberId string `path:"MemberId" maxLength:"10" example:"2333333333" doc:"Member Id"` LogtoId string `json:"logtoId" doc:"Logto Id"` Name string `json:"name" minLength:"2" maxLength:"4" doc:"Name"` @@ -201,7 +165,7 @@ func (MemberRouter) CreateWithLogto(c context.Context, input *struct { member.GithubId = gh.UserId } - if err = service.MemberServiceApp.CreateMember(member); err != nil { + if err = r.memberService.CreateMember(member); err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } @@ -209,7 +173,7 @@ func (MemberRouter) CreateWithLogto(c context.Context, input *struct { } -func (MemberRouter) CreateMany(ctx context.Context, input *CreateManyMembersInput) (*util.CommonResponse[[]model.Member], error) { +func (r *MemberRouter) CreateMany(ctx context.Context, input *CreateManyMembersInput) (*util.CommonResponse[[]model.Member], error) { _, err := middleware.AuthenticateUser(input.Authorization, "admin") if err != nil { return nil, err @@ -218,12 +182,12 @@ func (MemberRouter) CreateMany(ctx context.Context, input *CreateManyMembersInpu return nil, huma.Error501NotImplemented("CreateMany not implemented") } -func (MemberRouter) Activate(ctx context.Context, input *ActivateMemberInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) Activate(ctx context.Context, input *ActivateMemberInput) (*util.CommonResponse[model.Member], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "member_inactive", "admin_inactive") if err != nil { return nil, err } - member, err := service.MemberServiceApp.GetMemberById(auth.ID) + member, err := r.memberService.GetMemberById(auth.ID) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } @@ -240,19 +204,19 @@ func (MemberRouter) Activate(ctx context.Context, input *ActivateMemberInput) (* if input.Body.Profile != "" { member.Profile = input.Body.Profile } - err = service.MemberServiceApp.ActivateMember(member) + err = r.memberService.ActivateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) Update(ctx context.Context, input *UpdateMemberInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) Update(ctx context.Context, input *UpdateMemberInput) (*util.CommonResponse[model.Member], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "member", "admin") if err != nil { return nil, err } - member, err := service.MemberServiceApp.GetMemberById(auth.ID) + member, err := r.memberService.GetMemberById(auth.ID) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } @@ -275,21 +239,21 @@ func (MemberRouter) Update(ctx context.Context, input *UpdateMemberInput) (*util if input.Body.Password != "" { member.Password = input.Body.Password } - err = service.MemberServiceApp.UpdateMember(member) + err = r.memberService.UpdateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) BindMemberLogtoId(c context.Context, input *struct { +func (r *MemberRouter) BindMemberLogtoId(c context.Context, input *struct { MemberId string `path:"MemberId" maxLength:"10" example:"2333333333" doc:"Member Id"` Authorization string `header:"Authorization"` Body struct { Password string `json:"password"` } }) (*util.CommonResponse[model.Member], error) { - member, err := service.MemberServiceApp.GetMemberById(input.MemberId) + member, err := r.memberService.GetMemberById(input.MemberId) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } @@ -309,19 +273,19 @@ func (MemberRouter) BindMemberLogtoId(c context.Context, input *struct { } member.LogtoId = user.Id - err = service.MemberServiceApp.UpdateMember(member) + err = r.memberService.UpdateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) UpdateBasic(ctx context.Context, input *UpdateMemberBasicInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) UpdateBasic(ctx context.Context, input *UpdateMemberBasicInput) (*util.CommonResponse[model.Member], error) { _, err := middleware.AuthenticateUser(input.Authorization, "admin") if err != nil { return nil, err } - member, err := service.MemberServiceApp.GetMemberById(input.MemberId) + member, err := r.memberService.GetMemberById(input.MemberId) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } @@ -334,28 +298,27 @@ func (MemberRouter) UpdateBasic(ctx context.Context, input *UpdateMemberBasicInp if input.Body.Role != "" { member.Role = input.Body.Role } - err = service.MemberServiceApp.UpdateMember(member) + err = r.memberService.UpdateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -func (MemberRouter) UpdateAvatar(ctx context.Context, input *UpdateMemberAvatarInput) (*util.CommonResponse[model.Member], error) { +func (r *MemberRouter) UpdateAvatar(ctx context.Context, input *UpdateMemberAvatarInput) (*util.CommonResponse[model.Member], error) { auth, err := middleware.AuthenticateUser(input.Authorization, "member", "admin") if err != nil { return nil, err } - member, err := service.MemberServiceApp.GetMemberById(auth.ID) + member, err := r.memberService.GetMemberById(auth.ID) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } member.Avatar = input.Body.Avatar - err = service.MemberServiceApp.UpdateMember(member) + err = r.memberService.UpdateMember(member) if err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(member), nil } -var MemberRouterApp = new(MemberRouter) diff --git a/service/interfaces.go b/service/interfaces.go new file mode 100644 index 0000000..c3ab16f --- /dev/null +++ b/service/interfaces.go @@ -0,0 +1,85 @@ +package service + +import ( + "github.com/nbtca/saturday/model" + "github.com/nbtca/saturday/model/dto" +) + +// MemberServiceInterface defines the contract for member-related operations +type MemberServiceInterface interface { + // Member retrieval + GetMemberById(id string) (model.Member, error) + GetMemberByLogtoId(logtoId string) (model.Member, error) + GetMemberByGithubId(githubId string) (model.Member, error) + GetPublicMemberById(id string) (model.PublicMember, error) + GetPublicMembers(offset uint64, limit uint64) ([]model.PublicMember, error) + GetMembers(offset uint64, limit uint64) ([]model.Member, error) + + // Member management + CreateMember(member *model.Member) error + UpdateMember(member model.Member) error + ActivateMember(member model.Member) error + + // Authentication and role management + CreateToken(member model.Member) (string, error) + MapLogtoUserRole(roles []LogtoUserRole) string + + // New methods to handle business logic from router layer + GetOrCreateMemberByLogtoId(logtoId string) (model.Member, error) + SyncMemberProfile(member model.Member, logtoUser *FetchLogtoUsersResponse) error + AuthenticateAndSyncMember(logtoToken string) (model.Member, string, error) +} + +// LogtoServiceInterface defines the contract for Logto authentication operations +type LogtoServiceInterface interface { + // Token operations + FetchLogtoToken(resource string, scope string) (map[string]interface{}, error) + + // User operations + FetchUsers(request FetchLogtoUsersRequest) ([]FetchLogtoUsersResponse, error) + FetchUserById(userId string) (*FetchLogtoUsersResponse, error) + FetchUserByToken(token string) (*FetchLogtoUsersResponse, error) + FetchUserInfo(accessToken string) (FetchUserInfoResponse, error) + PatchUserById(userId string, data dto.PatchLogtoUserRequest) (map[string]interface{}, error) + + // Role operations + FetchUserRole(userId string) (FetchUserRoleResponse, error) +} + +// EventServiceInterface defines the contract for event-related operations +type EventServiceInterface interface { + // Event retrieval with proper authorization + GetEventByIdWithAuth(eventId string, memberId int, clientId int) (interface{}, error) + + // Event filtering and export + GetEventsWithFilter(filter interface{}, offset uint64, limit uint64) ([]interface{}, error) + ExportEventsToExcel(filter interface{}) ([]byte, string, error) + + // Event management + CreateEvent(event interface{}) error + UpdateEvent(event interface{}) error + DeleteEvent(eventId string, memberId int, clientId int) error +} + +// ClientServiceInterface defines the contract for client-related operations +type ClientServiceInterface interface { + GetClientById(id string) (interface{}, error) + CreateClient(client interface{}) error + UpdateClient(client interface{}) error + DeleteClient(id string) error +} + +// AuthServiceInterface defines the contract for authentication operations +type AuthServiceInterface interface { + // Authentication + ValidateLogtoToken(token string) (*FetchLogtoUsersResponse, error) + ValidateLegacyJWT(token string) (interface{}, error) + + // Authorization + CheckPermission(memberId int, resource string, action string) error + CheckEventAccess(eventId string, memberId int, clientId int) error + + // Role management + MapUserRoles(logtoRoles []LogtoUserRole) string + SyncUserRole(memberId int, newRole string) error +} \ No newline at end of file diff --git a/service/logto.go b/service/logto.go index e98029a..9d03fe2 100644 --- a/service/logto.go +++ b/service/logto.go @@ -24,6 +24,13 @@ type LogtoService struct { token string } +// NewLogtoService creates a new LogtoService instance +func NewLogtoService(baseURL string) LogtoServiceInterface { + return &LogtoService{ + BaseURL: baseURL, + } +} + func (l LogtoService) getToken() (string, error) { validate := func(token string) bool { diff --git a/service/member.go b/service/member.go index 06a29ed..f0e8240 100644 --- a/service/member.go +++ b/service/member.go @@ -4,14 +4,28 @@ import ( "net/http" "github.com/nbtca/saturday/model" + "github.com/nbtca/saturday/model/dto" "github.com/nbtca/saturday/repo" "github.com/nbtca/saturday/util" ) -type MemberService struct{} +type MemberService struct { + memberRepo repo.MemberRepository + roleRepo repo.RoleRepository + logtoService LogtoServiceInterface +} + +// NewMemberService creates a new MemberService with injected dependencies +func NewMemberService(memberRepo repo.MemberRepository, roleRepo repo.RoleRepository, logtoService LogtoServiceInterface) MemberServiceInterface { + return &MemberService{ + memberRepo: memberRepo, + roleRepo: roleRepo, + logtoService: logtoService, + } +} func (service *MemberService) GetMemberById(id string) (model.Member, error) { - member, err := repo.GetMemberById(id) + member, err := service.memberRepo.GetMemberById(id) if err != nil { return model.Member{}, err } @@ -25,7 +39,7 @@ func (service *MemberService) GetMemberById(id string) (model.Member, error) { } func (service *MemberService) GetMemberByLogtoId(logtoId string) (model.Member, error) { - member, err := repo.GetMemberByLogtoId(logtoId) + member, err := service.memberRepo.GetMemberByLogtoId(logtoId) if err != nil { return model.Member{}, err } @@ -39,7 +53,7 @@ func (service *MemberService) GetMemberByLogtoId(logtoId string) (model.Member, } func (service *MemberService) GetMemberByGithubId(githubId string) (model.Member, error) { - member, err := repo.GetMemberByGithubId(githubId) + member, err := service.memberRepo.GetMemberByGithubId(githubId) if err != nil { return model.Member{}, err } @@ -61,7 +75,7 @@ func (service *MemberService) GetPublicMemberById(id string) (model.PublicMember } func (service *MemberService) GetPublicMembers(offset uint64, limit uint64) ([]model.PublicMember, error) { - members, err := repo.GetMembers(offset, limit) + members, err := service.memberRepo.GetMembers(offset, limit) if err != nil { return nil, err } @@ -73,7 +87,7 @@ func (service *MemberService) GetPublicMembers(offset uint64, limit uint64) ([]m } func (service *MemberService) GetMembers(offset uint64, limit uint64) ([]model.Member, error) { - members, err := repo.GetMembers(offset, limit) + members, err := service.memberRepo.GetMembers(offset, limit) if err != nil { return nil, err } @@ -87,7 +101,7 @@ func (service *MemberService) CreateMember(member *model.Member) error { SetMessage("Validation Failed"). AddDetailError("member", "role", "invalid role") } - exist, err := repo.ExistMember(member.MemberId) + exist, err := service.memberRepo.ExistMember(member.MemberId) if err != nil { return err } @@ -100,7 +114,7 @@ func (service *MemberService) CreateMember(member *model.Member) error { if member.Role == "admin" { member.Role = "admin_inactive" } - if err := repo.CreateMember(member); err != nil { + if err := service.memberRepo.CreateMember(member); err != nil { return err } return nil @@ -112,7 +126,7 @@ func (service *MemberService) CreateToken(member model.Member) (string, error) { } // func (service *MemberService) UpdateBasic(member model.Member) error { -// exist, err := repo.ExistRole(member.Role) +// exist, err := service.roleRepo.ExistRole(member.Role) // if err != nil { // return err // } @@ -122,7 +136,7 @@ func (service *MemberService) CreateToken(member model.Member) (string, error) { // SetMessage("Validation Failed"). // AddDetailError("member", "role", "invalid role") // } -// if err := repo.UpdateMember(member); err != nil { +// if err := service.memberRepo.UpdateMember(member); err != nil { // return err // } // return nil @@ -141,7 +155,7 @@ func (service MemberService) MapLogtoUserRole(roles []LogtoUserRole) string { } func (service *MemberService) UpdateMember(member model.Member) error { - exist, err := repo.ExistRole(member.Role) + exist, err := service.roleRepo.ExistRole(member.Role) if err != nil { return err } @@ -151,7 +165,7 @@ func (service *MemberService) UpdateMember(member model.Member) error { SetMessage("Validation Failed"). AddDetailError("member", "role", "invalid role") } - if err := repo.UpdateMember(member); err != nil { + if err := service.memberRepo.UpdateMember(member); err != nil { return err } return nil @@ -164,7 +178,90 @@ func (service *MemberService) ActivateMember(member model.Member) error { if member.Role == "admin_inactive" { member.Role = "admin" } - if err := repo.UpdateMember(member); err != nil { + if err := service.memberRepo.UpdateMember(member); err != nil { + return err + } + return nil +} + +// GetOrCreateMemberByLogtoId gets a member by Logto ID, handling the business logic +func (service *MemberService) GetOrCreateMemberByLogtoId(logtoId string) (model.Member, error) { + memberId, err := service.memberRepo.GetMemberIdByLogtoId(logtoId) + if err != nil || !memberId.Valid { + return model.Member{}, util.MakeServiceError(http.StatusUnprocessableEntity). + SetMessage("Validation Failed"). + AddDetailError("member", "logtoId", "member not found for logto id") + } + + return service.GetMemberById(memberId.String) +} + +// AuthenticateAndSyncMember handles the complete authentication and sync flow from router +func (service *MemberService) AuthenticateAndSyncMember(logtoToken string) (model.Member, string, error) { + // Validate Logto token and get user info + user, err := service.logtoService.FetchUserByToken(logtoToken) + if err != nil { + return model.Member{}, "", err + } + if user.Id == "" { + return model.Member{}, "", util.MakeServiceError(http.StatusUnprocessableEntity). + SetMessage("Invalid token: id missing") + } + + // Get or create member + member, err := service.GetOrCreateMemberByLogtoId(user.Id) + if err != nil { + return model.Member{}, "", err + } + + // Sync role from Logto + logtoRoles, err := service.logtoService.FetchUserRole(user.Id) + if err != nil { + return model.Member{}, "", err + } + + mappedRole := service.MapLogtoUserRole(logtoRoles) + if mappedRole != member.Role && mappedRole != "" { + member.Role = mappedRole + err = service.UpdateMember(member) + if err != nil { + return model.Member{}, "", util.MakeServiceError(http.StatusUnprocessableEntity). + SetMessage("error at syncing member role: " + err.Error()) + } + } + + // Sync profile to Logto if needed + err = service.SyncMemberProfile(member, user) + if err != nil { + // Log error but don't fail the authentication + // This is non-critical operation + } + + // Create JWT token + token, err := service.CreateToken(member) + if err != nil { + return model.Member{}, "", err + } + + return member, token, nil +} + +// SyncMemberProfile syncs member profile data to Logto user +func (service *MemberService) SyncMemberProfile(member model.Member, logtoUser *FetchLogtoUsersResponse) error { + patchRequest := dto.PatchLogtoUserRequest{} + needsUpdate := false + + if member.Alias != "" && logtoUser.Name == "" { + patchRequest.Name = member.Alias + needsUpdate = true + } + if member.Avatar != "" && logtoUser.Avatar == "" { + patchRequest.Avatar = member.Avatar + needsUpdate = true + } + + if needsUpdate { + _, err := service.logtoService.PatchUserById(logtoUser.Id, patchRequest) return err } return nil diff --git a/service/member_di_test.go b/service/member_di_test.go new file mode 100644 index 0000000..adc9b9b --- /dev/null +++ b/service/member_di_test.go @@ -0,0 +1,225 @@ +package service + +import ( + "database/sql" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/nbtca/saturday/model" + "github.com/nbtca/saturday/model/dto" +) + +// Mock implementations for testing dependency injection + +type mockMemberRepository struct { + members map[string]model.Member +} + +func newMockMemberRepository() *mockMemberRepository { + return &mockMemberRepository{ + members: make(map[string]model.Member), + } +} + +func (m *mockMemberRepository) GetMemberById(id string) (model.Member, error) { + if member, exists := m.members[id]; exists { + return member, nil + } + return model.Member{}, nil +} + +func (m *mockMemberRepository) GetMemberByLogtoId(logtoId string) (model.Member, error) { + for _, member := range m.members { + if member.LogtoId == logtoId { + return member, nil + } + } + return model.Member{}, nil +} + +func (m *mockMemberRepository) GetMemberByGithubId(githubId string) (model.Member, error) { + for _, member := range m.members { + if member.GithubId == githubId { + return member, nil + } + } + return model.Member{}, nil +} + +func (m *mockMemberRepository) GetMemberIdByLogtoId(logtoId string) (sql.NullString, error) { + for id, member := range m.members { + if member.LogtoId == logtoId { + return sql.NullString{String: id, Valid: true}, nil + } + } + return sql.NullString{Valid: false}, nil +} + +func (m *mockMemberRepository) GetMembers(offset uint64, limit uint64) ([]model.Member, error) { + var result []model.Member + for _, member := range m.members { + result = append(result, member) + } + return result, nil +} + +func (m *mockMemberRepository) CreateMember(member *model.Member) error { + m.members[member.MemberId] = *member + return nil +} + +func (m *mockMemberRepository) UpdateMember(member model.Member) error { + m.members[member.MemberId] = member + return nil +} + +func (m *mockMemberRepository) ExistMember(id string) (bool, error) { + _, exists := m.members[id] + return exists, nil +} + +type mockRoleRepository struct{} + +func newMockRoleRepository() *mockRoleRepository { + return &mockRoleRepository{} +} + +func (m *mockRoleRepository) ExistRole(role string) (bool, error) { + validRoles := []string{"admin", "member", "admin_inactive", "member_inactive"} + for _, validRole := range validRoles { + if role == validRole { + return true, nil + } + } + return false, nil +} + +func (m *mockRoleRepository) SetMemberRole(memberId string, role string, conn *sqlx.Tx) error { + return nil +} + +type mockLogtoService struct{} + +func newMockLogtoService() *mockLogtoService { + return &mockLogtoService{} +} + +func (m *mockLogtoService) FetchLogtoToken(resource string, scope string) (map[string]interface{}, error) { + return map[string]interface{}{"access_token": "mock_token"}, nil +} + +func (m *mockLogtoService) FetchUsers(request FetchLogtoUsersRequest) ([]FetchLogtoUsersResponse, error) { + return []FetchLogtoUsersResponse{}, nil +} + +func (m *mockLogtoService) FetchUserById(userId string) (*FetchLogtoUsersResponse, error) { + return &FetchLogtoUsersResponse{Id: userId, Name: "Test User"}, nil +} + +func (m *mockLogtoService) FetchUserByToken(token string) (*FetchLogtoUsersResponse, error) { + return &FetchLogtoUsersResponse{Id: "test-user-id", Name: "Test User"}, nil +} + +func (m *mockLogtoService) FetchUserInfo(accessToken string) (FetchUserInfoResponse, error) { + return FetchUserInfoResponse{}, nil +} + +func (m *mockLogtoService) PatchUserById(userId string, data dto.PatchLogtoUserRequest) (map[string]interface{}, error) { + return map[string]interface{}{}, nil +} + +func (m *mockLogtoService) FetchUserRole(userId string) (FetchUserRoleResponse, error) { + return []LogtoUserRole{ + {Name: "Repair Member", Type: "test"}, + }, nil +} + +func TestMemberServiceDependencyInjection(t *testing.T) { + // Create mock dependencies + memberRepo := newMockMemberRepository() + roleRepo := newMockRoleRepository() + logtoService := newMockLogtoService() + + // Create service with injected dependencies + memberService := NewMemberService(memberRepo, roleRepo, logtoService) + + // Test service functionality + testMember := &model.Member{ + MemberId: "test123", + Alias: "Test User", + Role: "member", + LogtoId: "logto-123", + } + + // Test CreateMember + err := memberService.CreateMember(testMember) + if err != nil { + t.Errorf("CreateMember failed: %v", err) + } + + // Test GetMemberById + retrievedMember, err := memberService.GetMemberById("test123") + if err != nil { + t.Errorf("GetMemberById failed: %v", err) + } + + if retrievedMember.MemberId != testMember.MemberId { + t.Errorf("Expected MemberId %s, got %s", testMember.MemberId, retrievedMember.MemberId) + } + + // Test GetMemberByLogtoId + retrievedByLogto, err := memberService.GetMemberByLogtoId("logto-123") + if err != nil { + t.Errorf("GetMemberByLogtoId failed: %v", err) + } + + if retrievedByLogto.LogtoId != testMember.LogtoId { + t.Errorf("Expected LogtoId %s, got %s", testMember.LogtoId, retrievedByLogto.LogtoId) + } +} + +func TestMemberServiceMapLogtoUserRole(t *testing.T) { + // Create mock dependencies + memberRepo := newMockMemberRepository() + roleRepo := newMockRoleRepository() + logtoService := newMockLogtoService() + + // Create service with injected dependencies + memberService := NewMemberService(memberRepo, roleRepo, logtoService) + + // Cast to concrete type to access MapLogtoUserRole method + concreteMemberService, ok := memberService.(*MemberService) + if !ok { + t.Fatal("Failed to cast to concrete MemberService type") + } + + // Test role mapping + testCases := []struct { + roles []LogtoUserRole + expected string + }{ + { + roles: []LogtoUserRole{{Name: "Repair Admin"}}, + expected: "admin", + }, + { + roles: []LogtoUserRole{{Name: "Repair Member"}}, + expected: "member", + }, + { + roles: []LogtoUserRole{{Name: "Repair Admin"}, {Name: "Repair Member"}}, + expected: "admin", // Admin takes precedence + }, + { + roles: []LogtoUserRole{{Name: "Unknown Role"}}, + expected: "", + }, + } + + for _, tc := range testCases { + result := concreteMemberService.MapLogtoUserRole(tc.roles) + if result != tc.expected { + t.Errorf("Expected role %s, got %s for roles %v", tc.expected, result, tc.roles) + } + } +} \ No newline at end of file