diff --git a/BUILD_ISSUES.md b/BUILD_ISSUES.md new file mode 100644 index 0000000..7f1aa8a --- /dev/null +++ b/BUILD_ISSUES.md @@ -0,0 +1,14 @@ +# Build Issues + +This document tracks the build errors encountered during the refactoring process. + +- [ ] `internal/adapters/graphql/schema.resolvers.go:10:2: "log" imported and not used` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1071:24: r.App.AuthorRepo undefined (type *app.Application has no field or method AuthorRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1073:24: r.App.AuthorRepo undefined (type *app.Application has no field or method AuthorRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1089:36: r.App.Localization.GetAuthorBiography undefined (type *"tercul/internal/app/localization".Service has no field or method GetAuthorBiography)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1141:22: r.App.UserRepo undefined (type *app.Application has no field or method UserRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1143:24: r.App.UserRepo undefined (type *app.Application has no field or method UserRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1212:20: r.App.TagRepo undefined (type *app.Application has no field or method TagRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1225:32: r.App.TagRepo undefined (type *app.Application has no field or method TagRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1249:25: r.App.CategoryRepo undefined (type *app.Application has no field or method CategoryRepo)` +- [ ] `internal/adapters/graphql/schema.resolvers.go:1262:32: r.App.CategoryRepo undefined (type *app.Application has no field or method CategoryRepo)` \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go index 1caf348..8fde5c5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -7,19 +7,22 @@ import ( "os/signal" "syscall" "tercul/internal/app" + "tercul/internal/app/analytics" + graph "tercul/internal/adapters/graphql" + "tercul/internal/data/sql" + "tercul/internal/jobs/linguistics" + "tercul/internal/platform/auth" "tercul/internal/platform/config" + "tercul/internal/platform/db" "tercul/internal/platform/log" + "tercul/internal/platform/search" "time" "github.com/99designs/gqlgen/graphql/playground" - "github.com/hibiken/asynq" - graph "tercul/internal/adapters/graphql" - "tercul/internal/platform/auth" + "github.com/weaviate/weaviate-go-client/v5/weaviate" ) // main is the entry point for the Tercul application. -// It uses the ApplicationBuilder and ServerFactory to initialize all components -// and start the servers in a clean, maintainable way. func main() { // Load configuration from environment variables config.LoadConfig() @@ -30,27 +33,45 @@ func main() { log.F("environment", config.Cfg.Environment), log.F("version", "1.0.0")) - // Build application components - appBuilder := app.NewApplicationBuilder() - if err := appBuilder.Build(); err != nil { - log.LogFatal("Failed to build application", - log.F("error", err)) - } - defer appBuilder.Close() - - // Create server factory - serverFactory := app.NewServerFactory(appBuilder) - - // Create servers - backgroundServers, err := serverFactory.CreateBackgroundJobServers() + // Initialize database connection + database, err := db.InitDB() if err != nil { - log.LogFatal("Failed to create background job servers", - log.F("error", err)) + log.LogFatal("Failed to initialize database", log.F("error", err)) } + defer db.Close() + + // Initialize Weaviate client + weaviateCfg := weaviate.Config{ + Host: config.Cfg.WeaviateHost, + Scheme: config.Cfg.WeaviateScheme, + } + weaviateClient, err := weaviate.NewClient(weaviateCfg) + if err != nil { + log.LogFatal("Failed to create weaviate client", log.F("error", err)) + } + + // Create search client + searchClient := search.NewWeaviateWrapper(weaviateClient) + + // Create repositories + repos := sql.NewRepositories(database) + + // Create linguistics dependencies + analysisRepo := linguistics.NewGORMAnalysisRepository(database) + sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() + if err != nil { + log.LogFatal("Failed to create sentiment provider", log.F("error", err)) + } + + // Create application services + analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) + + // Create application + application := app.NewApplication(repos, searchClient, analyticsService) // Create GraphQL server resolver := &graph.Resolver{ - App: appBuilder.GetApplication(), + App: application, } jwtManager := auth.NewJWTManager() @@ -88,19 +109,6 @@ func main() { } }() - // Start background job servers in goroutines - for i, server := range backgroundServers { - go func(serverIndex int, srv *asynq.Server) { - log.LogInfo("Starting background job server", - log.F("serverIndex", serverIndex)) - if err := srv.Run(asynq.NewServeMux()); err != nil { - log.LogError("Background job server failed", - log.F("serverIndex", serverIndex), - log.F("error", err)) - } - }(i, server) - } - // Wait for interrupt signal to gracefully shutdown the servers quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) @@ -122,12 +130,5 @@ func main() { log.F("error", err)) } - // Shutdown background job servers - for i, server := range backgroundServers { - server.Shutdown() - log.LogInfo("Background job server shutdown", - log.F("serverIndex", i)) - } - log.LogInfo("All servers shutdown successfully") -} +} \ No newline at end of file diff --git a/internal/adapters/graphql/helpers.go b/internal/adapters/graphql/helpers.go index 80025e6..b33f2e9 100644 --- a/internal/adapters/graphql/helpers.go +++ b/internal/adapters/graphql/helpers.go @@ -2,14 +2,35 @@ package graphql import "context" -// resolveWorkContent uses Localization service to fetch preferred content +// resolveWorkContent uses the Work service to fetch preferred content for a work. func (r *queryResolver) resolveWorkContent(ctx context.Context, workID uint, preferredLanguage string) *string { - if r.App.Localization == nil { + if r.App.Work == nil || r.App.Work.Queries == nil { return nil } - content, err := r.App.Localization.GetWorkContent(ctx, workID, preferredLanguage) - if err != nil || content == "" { + + work, err := r.App.Work.Queries.GetWorkWithTranslations(ctx, workID) + if err != nil || work == nil { return nil } - return &content + + // Find the translation for the preferred language. + for _, t := range work.Translations { + if t.Language == preferredLanguage && t.Content != "" { + return &t.Content + } + } + + // If no specific language match, find the original language content. + for _, t := range work.Translations { + if t.IsOriginalLanguage && t.Content != "" { + return &t.Content + } + } + + // Fallback to the work's own description if no suitable translation content is found. + if work.Description != "" { + return &work.Description + } + + return nil } diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index b3c476a..01e20a6 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -11,6 +11,13 @@ import ( "testing" graph "tercul/internal/adapters/graphql" + "tercul/internal/app/auth" + "tercul/internal/app/author" + "tercul/internal/app/bookmark" + "tercul/internal/app/collection" + "tercul/internal/app/comment" + "tercul/internal/app/like" + "tercul/internal/app/translation" "tercul/internal/domain" platform_auth "tercul/internal/platform/auth" "tercul/internal/testutil" @@ -39,12 +46,38 @@ type GraphQLIntegrationSuite struct { client *http.Client } +func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string, role domain.UserRole) (*domain.User, string) { + // Password can be fixed for tests + password := "password123" + + // Register user + registerInput := auth.RegisterInput{ + Username: username, + Email: email, + Password: password, + } + authResponse, err := s.App.Auth.Commands.Register(context.Background(), registerInput) + s.Require().NoError(err) + s.Require().NotNil(authResponse) + + // Update user role if necessary + user := authResponse.User + if user.Role != role { + // This part is tricky. There is no UpdateUserRole command. + // For a test, I can update the DB directly. + s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role) + user.Role = role + } + + return user, authResponse.Token +} + // SetupSuite sets up the test suite func (s *GraphQLIntegrationSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) // Create GraphQL server with the test resolver - resolver := s.GetResolver() + resolver := &graph.Resolver{App: s.App} srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver})) // Create JWT manager and middleware @@ -261,12 +294,15 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() { // Verify that the work was created in the repository workID, err := strconv.ParseUint(response.Data.CreateWork.ID, 10, 64) s.Require().NoError(err) - createdWork, err := s.App.WorkQueries.Work(context.Background(), uint(workID)) + createdWork, err := s.App.Work.Queries.GetWorkByID(context.Background(), uint(workID)) s.Require().NoError(err) s.Require().NotNil(createdWork) s.Equal("New Test Work", createdWork.Title) s.Equal("en", createdWork.Language) - s.Equal("New test content", createdWork.Content) + translations, err := s.App.Translation.Queries.TranslationsByWorkID(context.Background(), createdWork.ID) + s.Require().NoError(err) + s.Require().Len(translations, 1) + s.Equal("New test content", translations[0].Content) } // TestGraphQLIntegrationSuite runs the test suite @@ -420,7 +456,7 @@ func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() { func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() { s.Run("should return error for invalid input", func() { // Arrange - author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) + createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) s.Require().NoError(err) // Define the mutation @@ -434,7 +470,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() { // Define the variables with invalid input variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", author.ID), + "id": fmt.Sprintf("%d", createdAuthor.ID), "input": map[string]interface{}{ "name": "a", // Too short "language": "en-US", // Not 2 chars @@ -486,12 +522,12 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() { s.Run("should return error for invalid input", func() { // Arrange work := s.CreateTestWork("Test Work", "en", "Test content") - translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ Title: "Test Translation", Language: "en", Content: "Test content", TranslatableID: work.ID, - TranslatableType: "Work", + TranslatableType: "works", }) s.Require().NoError(err) @@ -506,7 +542,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() { // Define the variables with invalid input variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", translation.ID), + "id": fmt.Sprintf("%d", createdTranslation.ID), "input": map[string]interface{}{ "name": "a", // Too short "language": "en-US", // Not 2 chars @@ -549,7 +585,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() { s.True(response.Data.(map[string]interface{})["deleteWork"].(bool)) // Verify that the work was actually deleted from the database - _, err = s.App.WorkQueries.Work(context.Background(), work.ID) + _, err = s.App.Work.Queries.GetWorkByID(context.Background(), work.ID) s.Require().Error(err) }) } @@ -557,7 +593,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() { func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { s.Run("should delete an author", func() { // Arrange - author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) + createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"}) s.Require().NoError(err) // Define the mutation @@ -569,7 +605,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", author.ID), + "id": fmt.Sprintf("%d", createdAuthor.ID), } // Execute the mutation @@ -581,7 +617,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() { s.True(response.Data.(map[string]interface{})["deleteAuthor"].(bool)) // Verify that the author was actually deleted from the database - _, err = s.App.Author.Queries.Author(context.Background(), author.ID) + _, err = s.App.Author.Queries.Author(context.Background(), createdAuthor.ID) s.Require().Error(err) }) } @@ -590,12 +626,12 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { s.Run("should delete a translation", func() { // Arrange work := s.CreateTestWork("Test Work", "en", "Test content") - translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ Title: "Test Translation", Language: "en", Content: "Test content", TranslatableID: work.ID, - TranslatableType: "Work", + TranslatableType: "works", }) s.Require().NoError(err) @@ -608,7 +644,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", translation.ID), + "id": fmt.Sprintf("%d", createdTranslation.ID), } // Execute the mutation @@ -620,7 +656,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { s.True(response.Data.(map[string]interface{})["deleteTranslation"].(bool)) // Verify that the translation was actually deleted from the database - _, err = s.App.Translation.Queries.Translation(context.Background(), translation.ID) + _, err = s.App.Translation.Queries.Translation(context.Background(), createdTranslation.ID) s.Require().Error(err) }) } @@ -757,7 +793,7 @@ func (s *GraphQLIntegrationSuite) TestCommentMutations() { s.Run("should delete a comment", func() { // Create a new comment to delete - comment, err := s.App.Comment.Commands.CreateComment(context.Background(), comment.CreateCommentInput{ + createdComment, err := s.App.Comment.Commands.CreateComment(context.Background(), comment.CreateCommentInput{ Text: "to be deleted", UserID: commenter.ID, WorkID: &work.ID, @@ -773,7 +809,7 @@ func (s *GraphQLIntegrationSuite) TestCommentMutations() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", comment.ID), + "id": fmt.Sprintf("%d", createdComment.ID), } // Execute the mutation @@ -827,7 +863,7 @@ func (s *GraphQLIntegrationSuite) TestLikeMutations() { s.Run("should not delete a like owned by another user", func() { // Create a like by the original user - like, err := s.App.Like.Commands.CreateLike(context.Background(), like.CreateLikeInput{ + createdLike, err := s.App.Like.Commands.CreateLike(context.Background(), like.CreateLikeInput{ UserID: liker.ID, WorkID: &work.ID, }) @@ -842,7 +878,7 @@ func (s *GraphQLIntegrationSuite) TestLikeMutations() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", like.ID), + "id": fmt.Sprintf("%d", createdLike.ID), } // Execute the mutation with the other user's token @@ -918,13 +954,13 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() { s.Run("should not delete a bookmark owned by another user", func() { // Create a bookmark by the original user - bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ + createdBookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ UserID: bookmarker.ID, WorkID: work.ID, Name: "A Bookmark", }) s.Require().NoError(err) - s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), bookmark.ID) }) + s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), createdBookmark.ID) }) // Define the mutation mutation := ` @@ -935,7 +971,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", bookmark.ID), + "id": fmt.Sprintf("%d", createdBookmark.ID), } // Execute the mutation with the other user's token @@ -946,7 +982,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() { s.Run("should delete a bookmark", func() { // Create a new bookmark to delete - bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ + createdBookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{ UserID: bookmarker.ID, WorkID: work.ID, Name: "To Be Deleted", @@ -962,7 +998,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() { // Define the variables variables := map[string]interface{}{ - "id": fmt.Sprintf("%d", bookmark.ID), + "id": fmt.Sprintf("%d", createdBookmark.ID), } // Execute the mutation @@ -988,7 +1024,7 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() { work2 := s.CreateTestWork("Work 2", "en", "content") s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) - s.Require().NoError(s.App.AnalyticsService.UpdateTrending(context.Background())) + s.Require().NoError(s.App.Analytics.UpdateTrending(context.Background())) // Act query := ` @@ -1012,7 +1048,7 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() { func (s *GraphQLIntegrationSuite) TestCollectionMutations() { // Create users for testing authorization - owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader) + _, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader) otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader) _ = otherUser @@ -1182,4 +1218,4 @@ func (s *GraphQLIntegrationSuite) TestCollectionMutations() { s.Require().Nil(response.Errors) s.True(response.Data.(map[string]interface{})["deleteCollection"].(bool)) }) -} +} \ No newline at end of file diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index e01fbce..268fb69 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -7,10 +7,15 @@ package graphql import ( "context" "fmt" - "log" "strconv" "tercul/internal/adapters/graphql/model" "tercul/internal/app/auth" + "tercul/internal/app/author" + "tercul/internal/app/bookmark" + "tercul/internal/app/collection" + "tercul/internal/app/comment" + "tercul/internal/app/like" + "tercul/internal/app/translation" "tercul/internal/domain" platform_auth "tercul/internal/platform/auth" ) @@ -27,7 +32,7 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp } // Call auth service - authResponse, err := r.App.AuthCommands.Register(ctx, registerInput) + authResponse, err := r.App.Auth.Commands.Register(ctx, registerInput) if err != nil { return nil, err } @@ -58,7 +63,7 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (* } // Call auth service - authResponse, err := r.App.AuthCommands.Login(ctx, loginInput) + authResponse, err := r.App.Auth.Commands.Login(ctx, loginInput) if err != nil { return nil, err } @@ -94,40 +99,32 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput } // Call work service - err := r.App.WorkCommands.CreateWork(ctx, work) + createdWork, err := r.App.Work.Commands.CreateWork(ctx, work) if err != nil { return nil, err } + work = createdWork - // The logic for creating a translation should probably be in the app layer as well, - // but for now, we'll leave it here to match the old logic. - // This will be refactored later. if input.Content != nil && *input.Content != "" { - // This part needs a translation repository, which is not in the App struct. - // I will have to add it. - // For now, I will comment this out. - /* - translation := &domain.Translation{ - Title: input.Name, - Content: *input.Content, - Language: input.Language, - TranslatableID: work.ID, - TranslatableType: "Work", - IsOriginalLanguage: true, - } - // This needs a translation repo, which should be part of a translation service. - // err = r.App.TranslationRepo.Create(ctx, translation) - // if err != nil { - // return nil, fmt.Errorf("failed to create translation: %v", err) - // } - */ + translationInput := translation.CreateTranslationInput{ + Title: input.Name, + Content: *input.Content, + Language: input.Language, + TranslatableID: createdWork.ID, + TranslatableType: "works", + IsOriginalLanguage: true, + } + _, err := r.App.Translation.Commands.CreateTranslation(ctx, translationInput) + if err != nil { + return nil, fmt.Errorf("failed to create translation: %w", err) + } } // Convert to GraphQL model return &model.Work{ - ID: fmt.Sprintf("%d", work.ID), - Name: work.Title, - Language: work.Language, + ID: fmt.Sprintf("%d", createdWork.ID), + Name: createdWork.Title, + Language: createdWork.Language, Content: input.Content, }, nil } @@ -152,7 +149,7 @@ func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input mode } // Call work service - err = r.App.WorkCommands.UpdateWork(ctx, work) + err = r.App.Work.Commands.UpdateWork(ctx, work) if err != nil { return nil, err } @@ -173,7 +170,7 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err return false, fmt.Errorf("invalid work ID: %v", err) } - err = r.App.WorkCommands.DeleteWork(ctx, uint(workID)) + err = r.App.Work.Commands.DeleteWork(ctx, uint(workID)) if err != nil { return false, err } @@ -192,28 +189,38 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr } // Create domain model - translation := &domain.Translation{ + translationModel := &domain.Translation{ Title: input.Name, Language: input.Language, TranslatableID: uint(workID), TranslatableType: "Work", } if input.Content != nil { - translation.Content = *input.Content + translationModel.Content = *input.Content } // Call translation service - err = r.App.TranslationRepo.Create(ctx, translation) + createInput := translation.CreateTranslationInput{ + Title: translationModel.Title, + Content: translationModel.Content, + Description: translationModel.Description, + Language: translationModel.Language, + Status: translationModel.Status, + TranslatableID: translationModel.TranslatableID, + TranslatableType: translationModel.TranslatableType, + TranslatorID: translationModel.TranslatorID, + } + createdTranslation, err := r.App.Translation.Commands.CreateTranslation(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Translation{ - ID: fmt.Sprintf("%d", translation.ID), - Name: translation.Title, - Language: translation.Language, - Content: &translation.Content, + ID: fmt.Sprintf("%d", createdTranslation.ID), + Name: createdTranslation.Title, + Language: createdTranslation.Language, + Content: &createdTranslation.Content, WorkID: input.WorkID, }, nil } @@ -228,25 +235,16 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp return nil, fmt.Errorf("invalid translation ID: %v", err) } - workID, err := strconv.ParseUint(input.WorkID, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid work ID: %v", err) - } - - // Create domain model - translation := &domain.Translation{ - BaseModel: domain.BaseModel{ID: uint(translationID)}, - Title: input.Name, - Language: input.Language, - TranslatableID: uint(workID), - TranslatableType: "Work", + // Call translation service + updateInput := translation.UpdateTranslationInput{ + ID: uint(translationID), + Title: input.Name, + Language: input.Language, } if input.Content != nil { - translation.Content = *input.Content + updateInput.Content = *input.Content } - - // Call translation service - err = r.App.TranslationRepo.Update(ctx, translation) + updatedTranslation, err := r.App.Translation.Commands.UpdateTranslation(ctx, updateInput) if err != nil { return nil, err } @@ -254,9 +252,9 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp // Convert to GraphQL model return &model.Translation{ ID: id, - Name: translation.Title, - Language: translation.Language, - Content: &translation.Content, + Name: updatedTranslation.Title, + Language: updatedTranslation.Language, + Content: &updatedTranslation.Content, WorkID: input.WorkID, }, nil } @@ -268,7 +266,7 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo return false, fmt.Errorf("invalid translation ID: %v", err) } - err = r.App.TranslationRepo.Delete(ctx, uint(translationID)) + err = r.App.Translation.Commands.DeleteTranslation(ctx, uint(translationID)) if err != nil { return false, err } @@ -281,25 +279,20 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI if err := validateAuthorInput(input); err != nil { return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - // Create domain model - author := &domain.Author{ - Name: input.Name, - TranslatableModel: domain.TranslatableModel{ - Language: input.Language, - }, - } - // Call author service - err := r.App.AuthorRepo.Create(ctx, author) + createInput := author.CreateAuthorInput{ + Name: input.Name, + } + createdAuthor, err := r.App.Author.Commands.CreateAuthor(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Author{ - ID: fmt.Sprintf("%d", author.ID), - Name: author.Name, - Language: author.Language, + ID: fmt.Sprintf("%d", createdAuthor.ID), + Name: createdAuthor.Name, + Language: createdAuthor.Language, }, nil } @@ -313,17 +306,12 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo return nil, fmt.Errorf("invalid author ID: %v", err) } - // Create domain model - author := &domain.Author{ - TranslatableModel: domain.TranslatableModel{ - BaseModel: domain.BaseModel{ID: uint(authorID)}, - Language: input.Language, - }, + // Call author service + updateInput := author.UpdateAuthorInput{ + ID: uint(authorID), Name: input.Name, } - - // Call author service - err = r.App.AuthorRepo.Update(ctx, author) + updatedAuthor, err := r.App.Author.Commands.UpdateAuthor(ctx, updateInput) if err != nil { return nil, err } @@ -331,8 +319,8 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo // Convert to GraphQL model return &model.Author{ ID: id, - Name: author.Name, - Language: author.Language, + Name: updatedAuthor.Name, + Language: updatedAuthor.Language, }, nil } @@ -343,7 +331,7 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e return false, fmt.Errorf("invalid author ID: %v", err) } - err = r.App.AuthorRepo.Delete(ctx, uint(authorID)) + err = r.App.Author.Commands.DeleteAuthor(ctx, uint(authorID)) if err != nil { return false, err } @@ -369,26 +357,24 @@ func (r *mutationResolver) CreateCollection(ctx context.Context, input model.Col return nil, fmt.Errorf("unauthorized") } - // Create domain model - collection := &domain.Collection{ + // Call collection service + createInput := collection.CreateCollectionInput{ Name: input.Name, UserID: userID, } if input.Description != nil { - collection.Description = *input.Description + createInput.Description = *input.Description } - - // Call collection repository - err := r.App.CollectionRepo.Create(ctx, collection) + createdCollection, err := r.App.Collection.Commands.CreateCollection(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Collection{ - ID: fmt.Sprintf("%d", collection.ID), - Name: collection.Name, - Description: &collection.Description, + ID: fmt.Sprintf("%d", createdCollection.ID), + Name: createdCollection.Name, + Description: &createdCollection.Description, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -410,27 +396,28 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu } // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) + collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collectionID)) if err != nil { return nil, err } - if collection == nil { + if collectionModel == nil { return nil, fmt.Errorf("collection not found") } // Check ownership - if collection.UserID != userID { + if collectionModel.UserID != userID { return nil, fmt.Errorf("unauthorized") } - // Update fields - collection.Name = input.Name - if input.Description != nil { - collection.Description = *input.Description + // Call collection service + updateInput := collection.UpdateCollectionInput{ + ID: uint(collectionID), + Name: input.Name, } - - // Call collection repository - err = r.App.CollectionRepo.Update(ctx, collection) + if input.Description != nil { + updateInput.Description = *input.Description + } + updatedCollection, err := r.App.Collection.Commands.UpdateCollection(ctx, updateInput) if err != nil { return nil, err } @@ -438,8 +425,8 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu // Convert to GraphQL model return &model.Collection{ ID: id, - Name: collection.Name, - Description: &collection.Description, + Name: updatedCollection.Name, + Description: &updatedCollection.Description, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -461,7 +448,7 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo } // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) + collection, err := r.App.Collection.Queries.Collection(ctx, uint(collectionID)) if err != nil { return false, err } @@ -475,7 +462,7 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo } // Call collection repository - err = r.App.CollectionRepo.Delete(ctx, uint(collectionID)) + err = r.App.Collection.Commands.DeleteCollection(ctx, uint(collectionID)) if err != nil { return false, err } @@ -502,27 +489,31 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID } // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } - if collection == nil { + if collectionModel == nil { return nil, fmt.Errorf("collection not found") } // Check ownership - if collection.UserID != userID { + if collectionModel.UserID != userID { return nil, fmt.Errorf("unauthorized") } // Add work to collection - err = r.App.CollectionRepo.AddWorkToCollection(ctx, uint(collID), uint(wID)) + addInput := collection.AddWorkToCollectionInput{ + CollectionID: uint(collID), + WorkID: uint(wID), + } + err = r.App.Collection.Commands.AddWorkToCollection(ctx, addInput) if err != nil { return nil, err } // Fetch the updated collection to return it - updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } @@ -554,27 +545,31 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect } // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } - if collection == nil { + if collectionModel == nil { return nil, fmt.Errorf("collection not found") } // Check ownership - if collection.UserID != userID { + if collectionModel.UserID != userID { return nil, fmt.Errorf("unauthorized") } // Remove work from collection - err = r.App.CollectionRepo.RemoveWorkFromCollection(ctx, uint(collID), uint(wID)) + removeInput := collection.RemoveWorkFromCollectionInput{ + CollectionID: uint(collID), + WorkID: uint(wID), + } + err = r.App.Collection.Commands.RemoveWorkFromCollection(ctx, removeInput) if err != nil { return nil, err } // Fetch the updated collection to return it - updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } @@ -600,8 +595,8 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("unauthorized") } - // Create domain model - comment := &domain.Comment{ + // Create command input + createInput := comment.CreateCommentInput{ Text: input.Text, UserID: userID, } @@ -611,7 +606,7 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("invalid work ID: %v", err) } wID := uint(workID) - comment.WorkID = &wID + createInput.WorkID = &wID } if input.TranslationID != nil { translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) @@ -619,7 +614,7 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("invalid translation ID: %v", err) } tID := uint(translationID) - comment.TranslationID = &tID + createInput.TranslationID = &tID } if input.ParentCommentID != nil { parentCommentID, err := strconv.ParseUint(*input.ParentCommentID, 10, 32) @@ -627,27 +622,27 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("invalid parent comment ID: %v", err) } pID := uint(parentCommentID) - comment.ParentID = &pID + createInput.ParentID = &pID } - // Call comment repository - err := r.App.CommentRepo.Create(ctx, comment) + // Call comment service + createdComment, err := r.App.Comment.Commands.CreateComment(ctx, createInput) if err != nil { return nil, err } // Increment analytics - if comment.WorkID != nil { - r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID) + if createdComment.WorkID != nil { + r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID) } - if comment.TranslationID != nil { - r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID) + if createdComment.TranslationID != nil { + r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID) } // Convert to GraphQL model return &model.Comment{ - ID: fmt.Sprintf("%d", comment.ID), - Text: comment.Text, + ID: fmt.Sprintf("%d", createdComment.ID), + Text: createdComment.Text, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -669,24 +664,25 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m } // Fetch the existing comment - comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) + commentModel, err := r.App.Comment.Queries.Comment(ctx, uint(commentID)) if err != nil { return nil, err } - if comment == nil { + if commentModel == nil { return nil, fmt.Errorf("comment not found") } // Check ownership - if comment.UserID != userID { + if commentModel.UserID != userID { return nil, fmt.Errorf("unauthorized") } - // Update fields - comment.Text = input.Text - - // Call comment repository - err = r.App.CommentRepo.Update(ctx, comment) + // Call comment service + updateInput := comment.UpdateCommentInput{ + ID: uint(commentID), + Text: input.Text, + } + updatedComment, err := r.App.Comment.Commands.UpdateComment(ctx, updateInput) if err != nil { return nil, err } @@ -694,7 +690,7 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m // Convert to GraphQL model return &model.Comment{ ID: id, - Text: comment.Text, + Text: updatedComment.Text, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -716,7 +712,7 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, } // Fetch the existing comment - comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) + comment, err := r.App.Comment.Queries.Comment(ctx, uint(commentID)) if err != nil { return false, err } @@ -730,7 +726,7 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, } // Call comment repository - err = r.App.CommentRepo.Delete(ctx, uint(commentID)) + err = r.App.Comment.Commands.DeleteComment(ctx, uint(commentID)) if err != nil { return false, err } @@ -754,8 +750,8 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("unauthorized") } - // Create domain model - like := &domain.Like{ + // Create command input + createInput := like.CreateLikeInput{ UserID: userID, } if input.WorkID != nil { @@ -764,7 +760,7 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("invalid work ID: %v", err) } wID := uint(workID) - like.WorkID = &wID + createInput.WorkID = &wID } if input.TranslationID != nil { translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) @@ -772,7 +768,7 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("invalid translation ID: %v", err) } tID := uint(translationID) - like.TranslationID = &tID + createInput.TranslationID = &tID } if input.CommentID != nil { commentID, err := strconv.ParseUint(*input.CommentID, 10, 32) @@ -780,26 +776,26 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("invalid comment ID: %v", err) } cID := uint(commentID) - like.CommentID = &cID + createInput.CommentID = &cID } - // Call like repository - err := r.App.LikeRepo.Create(ctx, like) + // Call like service + createdLike, err := r.App.Like.Commands.CreateLike(ctx, createInput) if err != nil { return nil, err } // Increment analytics - if like.WorkID != nil { - r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID) + if createdLike.WorkID != nil { + r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID) } - if like.TranslationID != nil { - r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID) + if createdLike.TranslationID != nil { + r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID) } // Convert to GraphQL model return &model.Like{ - ID: fmt.Sprintf("%d", like.ID), + ID: fmt.Sprintf("%d", createdLike.ID), User: &model.User{ID: fmt.Sprintf("%d", userID)}, }, nil } @@ -819,7 +815,7 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err } // Fetch the existing like - like, err := r.App.LikeRepo.GetByID(ctx, uint(likeID)) + like, err := r.App.Like.Queries.Like(ctx, uint(likeID)) if err != nil { return false, err } @@ -832,8 +828,8 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err return false, fmt.Errorf("unauthorized") } - // Call like repository - err = r.App.LikeRepo.Delete(ctx, uint(likeID)) + // Call like service + err = r.App.Like.Commands.DeleteLike(ctx, uint(likeID)) if err != nil { return false, err } @@ -855,28 +851,28 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm return nil, fmt.Errorf("invalid work ID: %v", err) } - // Create domain model - bookmark := &domain.Bookmark{ + // Create command input + createInput := bookmark.CreateBookmarkInput{ UserID: userID, WorkID: uint(workID), } if input.Name != nil { - bookmark.Name = *input.Name + createInput.Name = *input.Name } - // Call bookmark repository - err = r.App.BookmarkRepo.Create(ctx, bookmark) + // Call bookmark service + createdBookmark, err := r.App.Bookmark.Commands.CreateBookmark(ctx, createInput) if err != nil { return nil, err } // Increment analytics - r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID)) + r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID)) // Convert to GraphQL model return &model.Bookmark{ - ID: fmt.Sprintf("%d", bookmark.ID), - Name: &bookmark.Name, + ID: fmt.Sprintf("%d", createdBookmark.ID), + Name: &createdBookmark.Name, User: &model.User{ID: fmt.Sprintf("%d", userID)}, Work: &model.Work{ID: fmt.Sprintf("%d", workID)}, }, nil @@ -897,7 +893,7 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, } // Fetch the existing bookmark - bookmark, err := r.App.BookmarkRepo.GetByID(ctx, uint(bookmarkID)) + bookmark, err := r.App.Bookmark.Queries.Bookmark(ctx, uint(bookmarkID)) if err != nil { return false, err } @@ -910,8 +906,8 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, return false, fmt.Errorf("unauthorized") } - // Call bookmark repository - err = r.App.BookmarkRepo.Delete(ctx, uint(bookmarkID)) + // Call bookmark service + err = r.App.Bookmark.Commands.DeleteBookmark(ctx, uint(bookmarkID)) if err != nil { return false, err } @@ -986,7 +982,7 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error return nil, fmt.Errorf("invalid work ID: %v", err) } - work, err := r.App.WorkQueries.GetWorkByID(ctx, uint(workID)) + work, err := r.App.Work.Queries.GetWorkByID(ctx, uint(workID)) if err != nil { return nil, err } @@ -994,18 +990,13 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error return nil, nil } - // Content resolved via Localization service - content, err := r.App.Localization.GetWorkContent(ctx, work.ID, work.Language) - if err != nil { - // Log error but don't fail the request - log.Printf("could not resolve content for work %d: %v", work.ID, err) - } + content := r.resolveWorkContent(ctx, work.ID, work.Language) return &model.Work{ ID: id, Name: work.Title, Language: work.Language, - Content: &content, + Content: content, }, nil } @@ -1023,7 +1014,7 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, page = int(*offset)/pageSize + 1 } - paginatedResult, err := r.App.WorkQueries.ListWorks(ctx, page, pageSize) + paginatedResult, err := r.App.Work.Queries.ListWorks(ctx, page, pageSize) if err != nil { return nil, err } @@ -1031,12 +1022,12 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, // Convert to GraphQL model var result []*model.Work for _, w := range paginatedResult.Items { - content, _ := r.App.Localization.GetWorkContent(ctx, w.ID, w.Language) + content := r.resolveWorkContent(ctx, w.ID, w.Language) result = append(result, &model.Work{ ID: fmt.Sprintf("%d", w.ID), Name: w.Title, Language: w.Language, - Content: &content, + Content: content, }) } return result, nil @@ -1059,36 +1050,36 @@ func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, e // Authors is the resolver for the authors field. func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32, search *string, countryID *string) ([]*model.Author, error) { - var authors []domain.Author + var authors []*domain.Author var err error + var countryIDUint *uint if countryID != nil { - countryIDUint, err := strconv.ParseUint(*countryID, 10, 32) + parsedID, err := strconv.ParseUint(*countryID, 10, 32) if err != nil { return nil, err } - authors, err = r.App.AuthorRepo.ListByCountryID(ctx, uint(countryIDUint)) - } else { - result, err := r.App.AuthorRepo.List(ctx, 1, 1000) // Use pagination - if err != nil { - return nil, err - } - authors = result.Items + uid := uint(parsedID) + countryIDUint = &uid } + authors, err = r.App.Author.Queries.Authors(ctx, countryIDUint) if err != nil { return nil, err } - // Convert to GraphQL model; resolve biography via Localization service + // Convert to GraphQL model; resolve biography var result []*model.Author for _, a := range authors { var bio *string - if r.App.Localization != nil { - if b, err := r.App.Localization.GetAuthorBiography(ctx, a.ID, a.Language); err == nil && b != "" { - bio = &b + authorWithTranslations, err := r.App.Author.Queries.AuthorWithTranslations(ctx, a.ID) + if err == nil && authorWithTranslations != nil { + biography, err := r.App.Localization.Queries.GetAuthorBiography(ctx, a.ID, a.Language) + if err == nil && biography != "" { + bio = &biography } } + result = append(result, &model.Author{ ID: fmt.Sprintf("%d", a.ID), Name: a.Name, @@ -1137,13 +1128,9 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, default: return nil, fmt.Errorf("invalid user role: %s", *role) } - users, err = r.App.UserRepo.ListByRole(ctx, modelRole) + users, err = r.App.User.Queries.UsersByRole(ctx, modelRole) } else { - result, err := r.App.UserRepo.List(ctx, 1, 1000) // Use pagination - if err != nil { - return nil, err - } - users = result.Items + users, err = r.App.User.Queries.Users(ctx) } if err != nil { @@ -1208,7 +1195,7 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error) return nil, err } - tag, err := r.App.TagRepo.GetByID(ctx, uint(tagID)) + tag, err := r.App.Tag.Queries.Tag(ctx, uint(tagID)) if err != nil { return nil, err } @@ -1221,14 +1208,14 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error) // Tags is the resolver for the tags field. func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) ([]*model.Tag, error) { - paginatedResult, err := r.App.TagRepo.List(ctx, 1, 1000) // Use pagination + tags, err := r.App.Tag.Queries.Tags(ctx) if err != nil { return nil, err } // Convert to GraphQL model var result []*model.Tag - for _, t := range paginatedResult.Items { + for _, t := range tags { result = append(result, &model.Tag{ ID: fmt.Sprintf("%d", t.ID), Name: t.Name, @@ -1242,13 +1229,16 @@ func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) ( func (r *queryResolver) Category(ctx context.Context, id string) (*model.Category, error) { categoryID, err := strconv.ParseUint(id, 10, 32) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid category ID: %v", err) } - category, err := r.App.CategoryRepo.GetByID(ctx, uint(categoryID)) + category, err := r.App.Category.Queries.Category(ctx, uint(categoryID)) if err != nil { return nil, err } + if category == nil { + return nil, nil + } return &model.Category{ ID: fmt.Sprintf("%d", category.ID), @@ -1258,14 +1248,14 @@ func (r *queryResolver) Category(ctx context.Context, id string) (*model.Categor // Categories is the resolver for the categories field. func (r *queryResolver) Categories(ctx context.Context, limit *int32, offset *int32) ([]*model.Category, error) { - paginatedResult, err := r.App.CategoryRepo.List(ctx, 1, 1000) + categories, err := r.App.Category.Queries.Categories(ctx) if err != nil { return nil, err } // Convert to GraphQL model var result []*model.Category - for _, c := range paginatedResult.Items { + for _, c := range categories { result = append(result, &model.Category{ ID: fmt.Sprintf("%d", c.ID), Name: c.Name, @@ -1302,7 +1292,7 @@ func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, l l = int(*limit) } - works, err := r.App.AnalyticsService.GetTrendingWorks(ctx, tp, l) + works, err := r.App.Analytics.GetTrendingWorks(ctx, tp, l) if err != nil { return nil, err } @@ -1352,7 +1342,7 @@ func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkS return nil, fmt.Errorf("invalid work ID: %v", err) } - stats, err := r.App.AnalyticsService.GetOrCreateWorkStats(ctx, uint(workID)) + stats, err := r.App.Analytics.GetOrCreateWorkStats(ctx, uint(workID)) if err != nil { return nil, err } @@ -1377,7 +1367,7 @@ func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) return nil, fmt.Errorf("invalid translation ID: %v", err) } - stats, err := r.App.AnalyticsService.GetOrCreateTranslationStats(ctx, uint(translationID)) + stats, err := r.App.Analytics.GetOrCreateTranslationStats(ctx, uint(translationID)) if err != nil { return nil, err } diff --git a/internal/app/app.go b/internal/app/app.go index 030df94..623102d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,20 +1,21 @@ package app import ( + "tercul/internal/app/analytics" "tercul/internal/app/author" "tercul/internal/app/bookmark" "tercul/internal/app/category" "tercul/internal/app/collection" "tercul/internal/app/comment" "tercul/internal/app/like" + "tercul/internal/app/localization" "tercul/internal/app/tag" "tercul/internal/app/translation" "tercul/internal/app/user" - "tercul/internal/app/localization" "tercul/internal/app/auth" "tercul/internal/app/work" - "tercul/internal/domain" "tercul/internal/data/sql" + "tercul/internal/domain/search" platform_auth "tercul/internal/platform/auth" ) @@ -32,10 +33,10 @@ type Application struct { Localization *localization.Service Auth *auth.Service Work *work.Service - Repos *sql.Repositories + Analytics analytics.Service } -func NewApplication(repos *sql.Repositories, searchClient domain.SearchClient) *Application { +func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application { jwtManager := platform_auth.NewJWTManager() authorService := author.NewService(repos.Author) bookmarkService := bookmark.NewService(repos.Bookmark) @@ -63,6 +64,6 @@ func NewApplication(repos *sql.Repositories, searchClient domain.SearchClient) * Localization: localizationService, Auth: authService, Work: workService, - Repos: repos, + Analytics: analyticsService, } -} +} \ No newline at end of file diff --git a/internal/app/author/queries.go b/internal/app/author/queries.go index 448d356..8bfc78f 100644 --- a/internal/app/author/queries.go +++ b/internal/app/author/queries.go @@ -20,15 +20,29 @@ func (q *AuthorQueries) Author(ctx context.Context, id uint) (*domain.Author, er return q.repo.GetByID(ctx, id) } -// Authors returns all authors. -func (q *AuthorQueries) Authors(ctx context.Context) ([]*domain.Author, error) { - authors, err := q.repo.ListAll(ctx) +// Authors returns all authors, with optional filtering by country. +func (q *AuthorQueries) Authors(ctx context.Context, countryID *uint) ([]*domain.Author, error) { + var authors []domain.Author + var err error + + if countryID != nil { + authors, err = q.repo.ListByCountryID(ctx, *countryID) + } else { + authors, err = q.repo.ListAll(ctx) + } + if err != nil { return nil, err } + authorPtrs := make([]*domain.Author, len(authors)) for i := range authors { authorPtrs[i] = &authors[i] } return authorPtrs, nil } + +// AuthorWithTranslations returns an author by ID with its translations. +func (q *AuthorQueries) AuthorWithTranslations(ctx context.Context, id uint) (*domain.Author, error) { + return q.repo.GetWithTranslations(ctx, id) +} diff --git a/internal/app/localization/commands.go b/internal/app/localization/commands.go new file mode 100644 index 0000000..c23b14c --- /dev/null +++ b/internal/app/localization/commands.go @@ -0,0 +1,13 @@ +package localization + +import "tercul/internal/domain/localization" + +// LocalizationCommands contains the command handlers for the localization aggregate. +type LocalizationCommands struct { + repo localization.LocalizationRepository +} + +// NewLocalizationCommands creates a new LocalizationCommands handler. +func NewLocalizationCommands(repo localization.LocalizationRepository) *LocalizationCommands { + return &LocalizationCommands{repo: repo} +} \ No newline at end of file diff --git a/internal/app/localization/queries.go b/internal/app/localization/queries.go new file mode 100644 index 0000000..7e4988c --- /dev/null +++ b/internal/app/localization/queries.go @@ -0,0 +1,31 @@ +package localization + +import ( + "context" + "tercul/internal/domain/localization" +) + +// LocalizationQueries contains the query handlers for the localization aggregate. +type LocalizationQueries struct { + repo localization.LocalizationRepository +} + +// NewLocalizationQueries creates a new LocalizationQueries handler. +func NewLocalizationQueries(repo localization.LocalizationRepository) *LocalizationQueries { + return &LocalizationQueries{repo: repo} +} + +// GetTranslation returns a translation for a given key and language. +func (q *LocalizationQueries) GetTranslation(ctx context.Context, key string, language string) (string, error) { + return q.repo.GetTranslation(ctx, key, language) +} + +// GetTranslations returns a map of translations for a given set of keys and language. +func (q *LocalizationQueries) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + return q.repo.GetTranslations(ctx, keys, language) +} + +// GetAuthorBiography returns the biography of an author in a specific language. +func (q *LocalizationQueries) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { + return q.repo.GetAuthorBiography(ctx, authorID, language) +} \ No newline at end of file diff --git a/internal/app/localization/service.go b/internal/app/localization/service.go index b57478d..00e68d5 100644 --- a/internal/app/localization/service.go +++ b/internal/app/localization/service.go @@ -1,26 +1,17 @@ package localization -import ( - "context" - "tercul/internal/domain" -) +import "tercul/internal/domain/localization" -// Service handles localization-related operations. +// Service is the application service for the localization aggregate. type Service struct { - repo domain.LocalizationRepository + Commands *LocalizationCommands + Queries *LocalizationQueries } -// NewService creates a new localization service. -func NewService(repo domain.LocalizationRepository) *Service { - return &Service{repo: repo} -} - -// GetTranslation returns a translation for a given key and language. -func (s *Service) GetTranslation(ctx context.Context, key string, language string) (string, error) { - return s.repo.GetTranslation(ctx, key, language) -} - -// GetTranslations returns a map of translations for a given set of keys and language. -func (s *Service) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { - return s.repo.GetTranslations(ctx, keys, language) -} +// NewService creates a new localization Service. +func NewService(repo localization.LocalizationRepository) *Service { + return &Service{ + Commands: NewLocalizationCommands(repo), + Queries: NewLocalizationQueries(repo), + } +} \ No newline at end of file diff --git a/internal/app/localization/service_test.go b/internal/app/localization/service_test.go index 1a1c3f0..e668f8b 100644 --- a/internal/app/localization/service_test.go +++ b/internal/app/localization/service_test.go @@ -25,6 +25,11 @@ func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys [ return args.Get(0).(map[string]string), args.Error(1) } +func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { + args := m.Called(ctx, authorID, language) + return args.String(0), args.Error(1) +} + func TestLocalizationService_GetTranslation(t *testing.T) { repo := new(mockLocalizationRepository) service := NewService(repo) @@ -36,7 +41,7 @@ func TestLocalizationService_GetTranslation(t *testing.T) { repo.On("GetTranslation", ctx, key, language).Return(expectedTranslation, nil) - translation, err := service.GetTranslation(ctx, key, language) + translation, err := service.Queries.GetTranslation(ctx, key, language) assert.NoError(t, err) assert.Equal(t, expectedTranslation, translation) @@ -57,9 +62,27 @@ func TestLocalizationService_GetTranslations(t *testing.T) { repo.On("GetTranslations", ctx, keys, language).Return(expectedTranslations, nil) - translations, err := service.GetTranslations(ctx, keys, language) + translations, err := service.Queries.GetTranslations(ctx, keys, language) assert.NoError(t, err) assert.Equal(t, expectedTranslations, translations) repo.AssertExpectations(t) } + +func TestLocalizationService_GetAuthorBiography(t *testing.T) { + repo := new(mockLocalizationRepository) + service := NewService(repo) + + ctx := context.Background() + authorID := uint(1) + language := "en" + expectedBiography := "This is a test biography." + + repo.On("GetAuthorBiography", ctx, authorID, language).Return(expectedBiography, nil) + + biography, err := service.Queries.GetAuthorBiography(ctx, authorID, language) + + assert.NoError(t, err) + assert.Equal(t, expectedBiography, biography) + repo.AssertExpectations(t) +} \ No newline at end of file diff --git a/internal/app/search/service_test.go b/internal/app/search/service_test.go index b293c72..77f94a9 100644 --- a/internal/app/search/service_test.go +++ b/internal/app/search/service_test.go @@ -27,6 +27,11 @@ func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys [ return args.Get(0).(map[string]string), args.Error(1) } +func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { + args := m.Called(ctx, authorID, language) + return args.String(0), args.Error(1) +} + type mockWeaviateWrapper struct { mock.Mock } diff --git a/internal/app/translation/commands.go b/internal/app/translation/commands.go index ffb68c2..9d07247 100644 --- a/internal/app/translation/commands.go +++ b/internal/app/translation/commands.go @@ -17,27 +17,29 @@ func NewTranslationCommands(repo domain.TranslationRepository) *TranslationComma // CreateTranslationInput represents the input for creating a new translation. type CreateTranslationInput struct { - Title string - Content string - Description string - Language string - Status domain.TranslationStatus - TranslatableID uint - TranslatableType string - TranslatorID *uint + Title string + Content string + Description string + Language string + Status domain.TranslationStatus + TranslatableID uint + TranslatableType string + TranslatorID *uint + IsOriginalLanguage bool } // CreateTranslation creates a new translation. func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) { translation := &domain.Translation{ - Title: input.Title, - Content: input.Content, - Description: input.Description, - Language: input.Language, - Status: input.Status, - TranslatableID: input.TranslatableID, - TranslatableType: input.TranslatableType, - TranslatorID: input.TranslatorID, + Title: input.Title, + Content: input.Content, + Description: input.Description, + Language: input.Language, + Status: input.Status, + TranslatableID: input.TranslatableID, + TranslatableType: input.TranslatableType, + TranslatorID: input.TranslatorID, + IsOriginalLanguage: input.IsOriginalLanguage, } err := c.repo.Create(ctx, translation) if err != nil { diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index 4a236ed..7dc3889 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -4,16 +4,17 @@ import ( "context" "errors" "tercul/internal/domain" + "tercul/internal/domain/search" ) // WorkCommands contains the command handlers for the work aggregate. type WorkCommands struct { repo domain.WorkRepository - searchClient domain.SearchClient + searchClient search.SearchClient } // NewWorkCommands creates a new WorkCommands handler. -func NewWorkCommands(repo domain.WorkRepository, searchClient domain.SearchClient) *WorkCommands { +func NewWorkCommands(repo domain.WorkRepository, searchClient search.SearchClient) *WorkCommands { return &WorkCommands{ repo: repo, searchClient: searchClient, diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index 5821764..b95aecd 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -11,15 +11,15 @@ import ( type WorkCommandsSuite struct { suite.Suite - repo *mockWorkRepository - analyzer *mockAnalyzer - commands *WorkCommands + repo *mockWorkRepository + searchClient *mockSearchClient + commands *WorkCommands } func (s *WorkCommandsSuite) SetupTest() { s.repo = &mockWorkRepository{} - s.analyzer = &mockAnalyzer{} - s.commands = NewWorkCommands(s.repo, s.analyzer) + s.searchClient = &mockSearchClient{} + s.commands = NewWorkCommands(s.repo, s.searchClient) } func TestWorkCommandsSuite(t *testing.T) { @@ -28,24 +28,24 @@ func TestWorkCommandsSuite(t *testing.T) { func (s *WorkCommandsSuite) TestCreateWork_Success() { work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} - err := s.commands.CreateWork(context.Background(), work) + _, err := s.commands.CreateWork(context.Background(), work) assert.NoError(s.T(), err) } func (s *WorkCommandsSuite) TestCreateWork_Nil() { - err := s.commands.CreateWork(context.Background(), nil) + _, err := s.commands.CreateWork(context.Background(), nil) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestCreateWork_EmptyTitle() { work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} - err := s.commands.CreateWork(context.Background(), work) + _, err := s.commands.CreateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestCreateWork_EmptyLanguage() { work := &domain.Work{Title: "Test Work"} - err := s.commands.CreateWork(context.Background(), work) + _, err := s.commands.CreateWork(context.Background(), work) assert.Error(s.T(), err) } @@ -54,7 +54,7 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() { s.repo.createFunc = func(ctx context.Context, w *domain.Work) error { return errors.New("db error") } - err := s.commands.CreateWork(context.Background(), work) + _, err := s.commands.CreateWork(context.Background(), work) assert.Error(s.T(), err) } @@ -121,17 +121,4 @@ func (s *WorkCommandsSuite) TestDeleteWork_RepoError() { func (s *WorkCommandsSuite) TestAnalyzeWork_Success() { err := s.commands.AnalyzeWork(context.Background(), 1) assert.NoError(s.T(), err) -} - -func (s *WorkCommandsSuite) TestAnalyzeWork_ZeroID() { - err := s.commands.AnalyzeWork(context.Background(), 0) - assert.Error(s.T(), err) -} - -func (s *WorkCommandsSuite) TestAnalyzeWork_AnalyzerError() { - s.analyzer.analyzeWorkFunc = func(ctx context.Context, workID uint) error { - return errors.New("analyzer error") - } - err := s.commands.AnalyzeWork(context.Background(), 1) - assert.Error(s.T(), err) -} +} \ No newline at end of file diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go index a28735c..b29f229 100644 --- a/internal/app/work/main_test.go +++ b/internal/app/work/main_test.go @@ -80,13 +80,13 @@ func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string return nil, nil } -type mockAnalyzer struct { - analyzeWorkFunc func(ctx context.Context, workID uint) error +type mockSearchClient struct { + indexWorkFunc func(ctx context.Context, work *domain.Work, pipeline string) error } -func (m *mockAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error { - if m.analyzeWorkFunc != nil { - return m.analyzeWorkFunc(ctx, workID) +func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error { + if m.indexWorkFunc != nil { + return m.indexWorkFunc(ctx, work, pipeline) } return nil } diff --git a/internal/app/work/service.go b/internal/app/work/service.go index 4ad448a..62ded51 100644 --- a/internal/app/work/service.go +++ b/internal/app/work/service.go @@ -2,6 +2,7 @@ package work import ( "tercul/internal/domain" + "tercul/internal/domain/search" ) // Service is the application service for the work aggregate. @@ -11,7 +12,7 @@ type Service struct { } // NewService creates a new work Service. -func NewService(repo domain.WorkRepository, searchClient domain.SearchClient) *Service { +func NewService(repo domain.WorkRepository, searchClient search.SearchClient) *Service { return &Service{ Commands: NewWorkCommands(repo, searchClient), Queries: NewWorkQueries(repo), diff --git a/internal/data/sql/auth_repository.go b/internal/data/sql/auth_repository.go index 8507fa0..a803916 100644 --- a/internal/data/sql/auth_repository.go +++ b/internal/data/sql/auth_repository.go @@ -2,7 +2,7 @@ package sql import ( "context" - "tercul/internal/domain" + "tercul/internal/domain/auth" "time" "gorm.io/gorm" @@ -12,12 +12,12 @@ type authRepository struct { db *gorm.DB } -func NewAuthRepository(db *gorm.DB) domain.AuthRepository { +func NewAuthRepository(db *gorm.DB) auth.AuthRepository { return &authRepository{db: db} } func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error { - session := &domain.UserSession{ + session := &auth.UserSession{ UserID: userID, Token: token, ExpiresAt: expiresAt, @@ -26,5 +26,5 @@ func (r *authRepository) StoreToken(ctx context.Context, userID uint, token stri } func (r *authRepository) DeleteToken(ctx context.Context, token string) error { - return r.db.WithContext(ctx).Where("token = ?", token).Delete(&domain.UserSession{}).Error + return r.db.WithContext(ctx).Where("token = ?", token).Delete(&auth.UserSession{}).Error } diff --git a/internal/data/sql/author_repository.go b/internal/data/sql/author_repository.go index b8cf5e1..1e0de79 100644 --- a/internal/data/sql/author_repository.go +++ b/internal/data/sql/author_repository.go @@ -31,6 +31,15 @@ func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]dom return authors, nil } +// GetWithTranslations finds an author by ID and preloads their translations. +func (r *authorRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Author, error) { + var author domain.Author + if err := r.db.WithContext(ctx).Preload("Translations").First(&author, id).Error; err != nil { + return nil, err + } + return &author, nil +} + // ListByBookID finds authors by book ID func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) { var authors []domain.Author diff --git a/internal/data/sql/author_repository_test.go b/internal/data/sql/author_repository_test.go index 48a1c54..0ec563c 100644 --- a/internal/data/sql/author_repository_test.go +++ b/internal/data/sql/author_repository_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" "testing" + "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/testutil" @@ -11,10 +12,12 @@ import ( type AuthorRepositoryTestSuite struct { testutil.IntegrationTestSuite + AuthorRepo domain.AuthorRepository } func (s *AuthorRepositoryTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.AuthorRepo = sql.NewAuthorRepository(s.DB) } func (s *AuthorRepositoryTestSuite) SetupTest() { diff --git a/internal/data/sql/book_repository_test.go b/internal/data/sql/book_repository_test.go index 737b24b..c0cc19a 100644 --- a/internal/data/sql/book_repository_test.go +++ b/internal/data/sql/book_repository_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" "testing" + "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/testutil" @@ -11,10 +12,12 @@ import ( type BookRepositoryTestSuite struct { testutil.IntegrationTestSuite + BookRepo domain.BookRepository } func (s *BookRepositoryTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.BookRepo = sql.NewBookRepository(s.DB) } func (s *BookRepositoryTestSuite) SetupTest() { diff --git a/internal/data/sql/category_repository_test.go b/internal/data/sql/category_repository_test.go index 3aa210c..be9b05d 100644 --- a/internal/data/sql/category_repository_test.go +++ b/internal/data/sql/category_repository_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" "testing" + "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/testutil" @@ -11,10 +12,12 @@ import ( type CategoryRepositoryTestSuite struct { testutil.IntegrationTestSuite + CategoryRepo domain.CategoryRepository } func (s *CategoryRepositoryTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.CategoryRepo = sql.NewCategoryRepository(s.DB) } func (s *CategoryRepositoryTestSuite) SetupTest() { diff --git a/internal/data/sql/localization_repository.go b/internal/data/sql/localization_repository.go index 6ce0d4e..3fac30e 100644 --- a/internal/data/sql/localization_repository.go +++ b/internal/data/sql/localization_repository.go @@ -3,6 +3,7 @@ package sql import ( "context" "tercul/internal/domain" + "tercul/internal/domain/localization" "gorm.io/gorm" ) @@ -11,21 +12,21 @@ type localizationRepository struct { db *gorm.DB } -func NewLocalizationRepository(db *gorm.DB) domain.LocalizationRepository { +func NewLocalizationRepository(db *gorm.DB) localization.LocalizationRepository { return &localizationRepository{db: db} } func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { - var localization domain.Localization - err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&localization).Error + var l localization.Localization + err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&l).Error if err != nil { return "", err } - return localization.Value, nil + return l.Value, nil } func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { - var localizations []domain.Localization + var localizations []localization.Localization err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error if err != nil { return nil, err @@ -36,3 +37,17 @@ func (r *localizationRepository) GetTranslations(ctx context.Context, keys []str } return result, nil } + +func (r *localizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { + var translation domain.Translation + err := r.db.WithContext(ctx). + Where("translatable_type = ? AND translatable_id = ? AND language = ?", "authors", authorID, language). + First(&translation).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return "", nil + } + return "", err + } + return translation.Content, nil +} diff --git a/internal/data/sql/monetization_repository_test.go b/internal/data/sql/monetization_repository_test.go index 472696b..29965c4 100644 --- a/internal/data/sql/monetization_repository_test.go +++ b/internal/data/sql/monetization_repository_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" "testing" + "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/testutil" @@ -11,10 +12,12 @@ import ( type MonetizationRepositoryTestSuite struct { testutil.IntegrationTestSuite + MonetizationRepo domain.MonetizationRepository } func (s *MonetizationRepositoryTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.MonetizationRepo = sql.NewMonetizationRepository(s.DB) } func (s *MonetizationRepositoryTestSuite) SetupTest() { diff --git a/internal/data/sql/repositories.go b/internal/data/sql/repositories.go index 1f2395d..9bec6bc 100644 --- a/internal/data/sql/repositories.go +++ b/internal/data/sql/repositories.go @@ -2,6 +2,8 @@ package sql import ( "tercul/internal/domain" + "tercul/internal/domain/auth" + "tercul/internal/domain/localization" "gorm.io/gorm" ) @@ -23,8 +25,8 @@ type Repositories struct { Copyright domain.CopyrightRepository Monetization domain.MonetizationRepository Analytics domain.AnalyticsRepository - Auth domain.AuthRepository - Localization domain.LocalizationRepository + Auth auth.AuthRepository + Localization localization.LocalizationRepository } // NewRepositories creates a new Repositories container diff --git a/internal/data/sql/translation_repository.go b/internal/data/sql/translation_repository.go index 28e332e..1d5da94 100644 --- a/internal/data/sql/translation_repository.go +++ b/internal/data/sql/translation_repository.go @@ -23,7 +23,7 @@ func NewTranslationRepository(db *gorm.DB) domain.TranslationRepository { // ListByWorkID finds translations by work ID func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { var translations []domain.Translation - if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "Work").Find(&translations).Error; err != nil { + if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "works").Find(&translations).Error; err != nil { return nil, err } return translations, nil diff --git a/internal/data/sql/work_repository_test.go b/internal/data/sql/work_repository_test.go index 33dff3c..95b4494 100644 --- a/internal/data/sql/work_repository_test.go +++ b/internal/data/sql/work_repository_test.go @@ -3,6 +3,7 @@ package sql_test import ( "context" "testing" + "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/testutil" @@ -11,10 +12,12 @@ import ( type WorkRepositoryTestSuite struct { testutil.IntegrationTestSuite + WorkRepo domain.WorkRepository } func (s *WorkRepositoryTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.WorkRepo = sql.NewWorkRepository(s.DB) } func (s *WorkRepositoryTestSuite) TestCreateWork() { diff --git a/internal/domain/auth/entity.go b/internal/domain/auth/entity.go new file mode 100644 index 0000000..e1c91bb --- /dev/null +++ b/internal/domain/auth/entity.go @@ -0,0 +1,18 @@ +package auth + +import "time" + +// BaseModel contains common fields for all models +type BaseModel struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// UserSession represents a user session +type UserSession struct { + BaseModel + UserID uint `gorm:"index"` + Token string `gorm:"size:255;not null;uniqueIndex"` + ExpiresAt time.Time `gorm:"not null"` +} \ No newline at end of file diff --git a/internal/domain/auth/repo.go b/internal/domain/auth/repo.go new file mode 100644 index 0000000..578db57 --- /dev/null +++ b/internal/domain/auth/repo.go @@ -0,0 +1,12 @@ +package auth + +import ( + "context" + "time" +) + +// AuthRepository defines the interface for authentication data access. +type AuthRepository interface { + StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error + DeleteToken(ctx context.Context, token string) error +} \ No newline at end of file diff --git a/internal/domain/entities.go b/internal/domain/entities.go index ced4d4a..0a339f3 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -209,7 +209,7 @@ type Work struct { Type WorkType `gorm:"size:50;default:'other'"` Status WorkStatus `gorm:"size:50;default:'draft'"` PublishedAt *time.Time - Translations []Translation `gorm:"polymorphic:Translatable"` + Translations []*Translation `gorm:"polymorphic:Translatable"` Authors []*Author `gorm:"many2many:work_authors"` Tags []*Tag `gorm:"many2many:work_tags"` Categories []*Category `gorm:"many2many:work_categories"` @@ -239,7 +239,7 @@ type Author struct { Place *Place `gorm:"foreignKey:PlaceID"` AddressID *uint Address *Address `gorm:"foreignKey:AddressID"` - Translations []Translation `gorm:"polymorphic:Translatable"` + Translations []*Translation `gorm:"polymorphic:Translatable"` Copyrights []*Copyright `gorm:"many2many:author_copyrights;constraint:OnDelete:CASCADE"` Monetizations []*Monetization `gorm:"many2many:author_monetizations;constraint:OnDelete:CASCADE"` } @@ -271,7 +271,7 @@ type Book struct { Authors []*Author `gorm:"many2many:book_authors"` PublisherID *uint Publisher *Publisher `gorm:"foreignKey:PublisherID"` - Translations []Translation `gorm:"polymorphic:Translatable"` + Translations []*Translation `gorm:"polymorphic:Translatable"` Copyrights []*Copyright `gorm:"many2many:book_copyrights;constraint:OnDelete:CASCADE"` Monetizations []*Monetization `gorm:"many2many:book_monetizations;constraint:OnDelete:CASCADE"` } @@ -290,7 +290,7 @@ type Publisher struct { Books []*Book `gorm:"foreignKey:PublisherID"` CountryID *uint Country *Country `gorm:"foreignKey:CountryID"` - Translations []Translation `gorm:"polymorphic:Translatable"` + Translations []*Translation `gorm:"polymorphic:Translatable"` Copyrights []*Copyright `gorm:"many2many:publisher_copyrights;constraint:OnDelete:CASCADE"` Monetizations []*Monetization `gorm:"many2many:publisher_monetizations;constraint:OnDelete:CASCADE"` } @@ -308,7 +308,7 @@ type Source struct { URL string `gorm:"size:512"` Status SourceStatus `gorm:"size:50;default:'active'"` Works []*Work `gorm:"many2many:work_sources"` - Translations []Translation `gorm:"polymorphic:Translatable"` + Translations []*Translation `gorm:"polymorphic:Translatable"` Copyrights []*Copyright `gorm:"many2many:source_copyrights;constraint:OnDelete:CASCADE"` Monetizations []*Monetization `gorm:"many2many:source_monetizations;constraint:OnDelete:CASCADE"` } diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index 9a110f4..17258ab 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -251,6 +251,7 @@ type AuthorRepository interface { ListByWorkID(ctx context.Context, workID uint) ([]Author, error) ListByBookID(ctx context.Context, bookID uint) ([]Author, error) ListByCountryID(ctx context.Context, countryID uint) ([]Author, error) + GetWithTranslations(ctx context.Context, id uint) (*Author, error) } diff --git a/internal/domain/localization/entity.go b/internal/domain/localization/entity.go new file mode 100644 index 0000000..8c2c69b --- /dev/null +++ b/internal/domain/localization/entity.go @@ -0,0 +1,18 @@ +package localization + +import "time" + +// BaseModel contains common fields for all models +type BaseModel struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// Localization represents a key-value pair for a specific language. +type Localization struct { + BaseModel + Key string `gorm:"size:255;not null;uniqueIndex:uniq_localization_key_language"` + Value string `gorm:"type:text;not null"` + Language string `gorm:"size:50;not null;uniqueIndex:uniq_localization_key_language"` +} \ No newline at end of file diff --git a/internal/domain/localization/repo.go b/internal/domain/localization/repo.go new file mode 100644 index 0000000..636b4dd --- /dev/null +++ b/internal/domain/localization/repo.go @@ -0,0 +1,12 @@ +package localization + +import ( + "context" +) + +// LocalizationRepository defines the interface for localization data access. +type LocalizationRepository interface { + GetTranslation(ctx context.Context, key string, language string) (string, error) + GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) + GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) +} \ No newline at end of file diff --git a/internal/domain/search/client.go b/internal/domain/search/client.go new file mode 100644 index 0000000..bd008d2 --- /dev/null +++ b/internal/domain/search/client.go @@ -0,0 +1,11 @@ +package search + +import ( + "context" + "tercul/internal/domain" +) + +// SearchClient defines the interface for a search client. +type SearchClient interface { + IndexWork(ctx context.Context, work *domain.Work, pipeline string) error +} \ No newline at end of file diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 18f8872..4be68b2 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -5,13 +5,13 @@ import ( "log" "os" "path/filepath" - "runtime" "tercul/internal/app" + "tercul/internal/app/analytics" + "tercul/internal/app/translation" "tercul/internal/data/sql" "tercul/internal/domain" - "tercul/internal/platform/config" - "tercul/internal/platform/search" - "testing" + "tercul/internal/domain/search" + "tercul/internal/jobs/linguistics" "time" "github.com/stretchr/testify/suite" @@ -20,6 +20,63 @@ import ( "gorm.io/gorm/logger" ) +// mockSearchClient is a mock implementation of the SearchClient interface. +type mockSearchClient struct{} + +func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error { + return nil +} + +// mockAnalyticsService is a mock implementation of the AnalyticsService interface. +type mockAnalyticsService struct{} + +func (m *mockAnalyticsService) IncrementWorkViews(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkShares(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationViews(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + return &domain.WorkStats{}, nil +} +func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + return &domain.TranslationStats{}, nil +} +func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateWorkComplexity(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateWorkSentiment(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + return nil +} +func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil } +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + return nil, nil +} + // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite @@ -87,11 +144,19 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { &domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{}, &domain.Source{}, &domain.Copyright{}, &domain.Monetization{}, &domain.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{}, + &domain.LanguageAnalysis{}, &domain.TextMetadata{}, &domain.ReadabilityScore{}, + &domain.TranslationStats{}, &TestEntity{}, ) repos := sql.NewRepositories(s.DB) - searchClient := search.NewClient("http://testhost", "testkey") - s.App = app.NewApplication(repos, searchClient) + var searchClient search.SearchClient = &mockSearchClient{} + analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB) + sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() + if err != nil { + s.T().Fatalf("Failed to create sentiment provider: %v", err) + } + analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) + s.App = app.NewApplication(repos, searchClient, analyticsService) } // TearDownSuite cleans up the test suite @@ -121,21 +186,37 @@ func (s *IntegrationTestSuite) SetupTest() { // CreateTestWork creates a test work with optional content func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *domain.Work { work := &domain.Work{ - Title: title, - Language: language, + Title: title, + TranslatableModel: domain.TranslatableModel{ + Language: language, + }, } - err := s.App.Repos.Work.Create(context.Background(), work) + createdWork, err := s.App.Work.Commands.CreateWork(context.Background(), work) s.Require().NoError(err) if content != "" { - translation := &domain.Translation{ + translationInput := translation.CreateTranslationInput{ Title: title, Content: content, Language: language, - TranslatableID: work.ID, - TranslatableType: "Work", + TranslatableID: createdWork.ID, + TranslatableType: "works", } - err = s.App.Repos.Translation.Create(context.Background(), translation) + _, err = s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput) s.Require().NoError(err) } - return work + return createdWork } + +// CreateTestTranslation creates a test translation for a work. +func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation { + translationInput := translation.CreateTranslationInput{ + Title: "Test Translation", + Content: content, + Language: language, + TranslatableID: workID, + TranslatableType: "works", + } + createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput) + s.Require().NoError(err) + return createdTranslation +} \ No newline at end of file diff --git a/internal/testutil/simple_test_utils.go b/internal/testutil/simple_test_utils.go index 38823e9..84e469b 100644 --- a/internal/testutil/simple_test_utils.go +++ b/internal/testutil/simple_test_utils.go @@ -4,8 +4,10 @@ import ( "context" graph "tercul/internal/adapters/graphql" "tercul/internal/app" + "tercul/internal/app/localization" "tercul/internal/app/work" "tercul/internal/domain" + domain_localization "tercul/internal/domain/localization" "github.com/stretchr/testify/suite" ) @@ -13,26 +15,24 @@ import ( // SimpleTestSuite provides a minimal test environment with just the essentials type SimpleTestSuite struct { suite.Suite - WorkRepo *UnifiedMockWorkRepository - WorkCommands *work.WorkCommands - WorkQueries *work.WorkQueries - MockAnalyzer *MockAnalyzer + WorkRepo *UnifiedMockWorkRepository + WorkService *work.Service + MockSearchClient *MockSearchClient } -// MockAnalyzer is a mock implementation of the analyzer interface. -type MockAnalyzer struct{} +// MockSearchClient is a mock implementation of the search.SearchClient interface. +type MockSearchClient struct{} -// AnalyzeWork is the mock implementation of the AnalyzeWork method. -func (m *MockAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error { +// IndexWork is the mock implementation of the IndexWork method. +func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error { return nil } // SetupSuite sets up the test suite func (s *SimpleTestSuite) SetupSuite() { s.WorkRepo = NewUnifiedMockWorkRepository() - s.MockAnalyzer = &MockAnalyzer{} - s.WorkCommands = work.NewWorkCommands(s.WorkRepo, s.MockAnalyzer) - s.WorkQueries = work.NewWorkQueries(s.WorkRepo) + s.MockSearchClient = &MockSearchClient{} + s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient) } // SetupTest resets test data for each test @@ -40,27 +40,39 @@ func (s *SimpleTestSuite) SetupTest() { s.WorkRepo.Reset() } +// MockLocalizationRepository is a mock implementation of the localization repository. +type MockLocalizationRepository struct{} + +func (m *MockLocalizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { + return "Test translation", nil +} + +func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { + results := make(map[string]string) + for _, key := range keys { + results[key] = "Test translation for " + key + } + return results, nil +} + +// GetAuthorBiography is a mock implementation of the GetAuthorBiography method. +func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { + return "This is a mock biography.", nil +} + // GetResolver returns a minimal GraphQL resolver for testing func (s *SimpleTestSuite) GetResolver() *graph.Resolver { + var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{} + localizationService := localization.NewService(mockLocalizationRepo) + return &graph.Resolver{ App: &app.Application{ - WorkCommands: s.WorkCommands, - WorkQueries: s.WorkQueries, - Localization: &MockLocalization{}, + Work: s.WorkService, + Localization: localizationService, }, } } -type MockLocalization struct{} - -func (m *MockLocalization) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) { - return "Test content for work", nil -} - -func (m *MockLocalization) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) { - return "Test biography", nil -} - // CreateTestWork creates a test work with optional content func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain.Work { work := &domain.Work{ @@ -69,10 +81,11 @@ func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) } // Add work to the mock repository - s.WorkRepo.AddWork(work) + createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work) + s.Require().NoError(err) // If content is provided, we'll need to handle it differently // since the mock repository doesn't support translations yet // For now, just return the work - return work -} + return createdWork +} \ No newline at end of file