diff --git a/cmd/api/main.go b/cmd/api/main.go index 1ea0219..8e68af8 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -8,9 +8,11 @@ import ( "os" "os/signal" "syscall" + "tercul/internal/adapters/graphql" "tercul/internal/app" "tercul/internal/app/analytics" - graph "tercul/internal/adapters/graphql" + "tercul/internal/app/localization" + appsearch "tercul/internal/app/search" dbsql "tercul/internal/data/sql" "tercul/internal/jobs/linguistics" "tercul/internal/observability" @@ -115,11 +117,14 @@ func main() { // Create application services analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) + localizationService := localization.NewService(repos.Localization) + searchService := appsearch.NewService(searchClient, localizationService) // Create application dependencies deps := app.Dependencies{ WorkRepo: repos.Work, UserRepo: repos.User, + UserProfileRepo: repos.UserProfile, AuthorRepo: repos.Author, TranslationRepo: repos.Translation, CommentRepo: repos.Comment, @@ -144,14 +149,14 @@ func main() { // Create application application := app.NewApplication(deps) + application.Search = searchService // Manually set the search service // Create GraphQL server - resolver := &graph.Resolver{ + resolver := &graphql.Resolver{ App: application, } // Create the main API handler with all middleware. - // NewServerWithAuth now returns the handler chain directly. apiHandler := NewServerWithAuth(resolver, jwtManager, metrics, obsLogger) // Create the main ServeMux and register all handlers. diff --git a/cmd/worker/main.go b/cmd/worker/main.go new file mode 100644 index 0000000..c206729 --- /dev/null +++ b/cmd/worker/main.go @@ -0,0 +1,91 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + "tercul/internal/jobs/sync" + "tercul/internal/platform/config" + "tercul/internal/platform/db" + app_log "tercul/internal/platform/log" + + "github.com/hibiken/asynq" + "github.com/weaviate/weaviate-go-client/v5/weaviate" +) + +func main() { + // Load configuration from environment variables + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("cannot load config: %v", err) + } + + // Initialize logger + app_log.Init("tercul-worker", cfg.Environment) + app_log.Info("Starting Tercul worker...") + + // Initialize database connection + database, err := db.InitDB(cfg, nil) // No metrics needed for the worker + if err != nil { + app_log.Fatal(err, "Failed to initialize database") + } + defer db.Close(database) + + // Initialize Weaviate client + weaviateCfg := weaviate.Config{ + Host: cfg.WeaviateHost, + Scheme: cfg.WeaviateScheme, + } + weaviateClient, err := weaviate.NewClient(weaviateCfg) + if err != nil { + app_log.Fatal(err, "Failed to create weaviate client") + } + + // Initialize Asynq client and server + redisConnection := asynq.RedisClientOpt{Addr: cfg.RedisAddr} + asynqClient := asynq.NewClient(redisConnection) + defer asynqClient.Close() + + srv := asynq.NewServer( + redisConnection, + asynq.Config{ + Concurrency: 10, // Example concurrency + Queues: map[string]int{ + "critical": 6, + "default": 3, + "low": 1, + }, + }, + ) + + // Create SyncJob with all dependencies + syncJob := sync.NewSyncJob(database, asynqClient, cfg, weaviateClient) + + // Create a new ServeMux for routing jobs + mux := asynq.NewServeMux() + + // Register all job handlers + sync.RegisterQueueHandlers(mux, syncJob) + // Placeholder for other job handlers that might be added in the future + // linguistics.RegisterLinguisticHandlers(mux, linguisticJob) + // trending.RegisterTrendingHandlers(mux, analyticsService) + + // Start the server in a goroutine + go func() { + if err := srv.Run(mux); err != nil { + app_log.Fatal(err, "Could not run asynq server") + } + }() + + app_log.Info("Worker started successfully.") + + // Wait for interrupt signal to gracefully shutdown the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + app_log.Info("Shutting down worker...") + srv.Shutdown() + app_log.Info("Worker shut down successfully.") +} \ No newline at end of file diff --git a/internal/adapters/graphql/analytics_service_mock_test.go b/internal/adapters/graphql/analytics_service_mock_test.go index 4686c17..db8737c 100644 --- a/internal/adapters/graphql/analytics_service_mock_test.go +++ b/internal/adapters/graphql/analytics_service_mock_test.go @@ -3,7 +3,6 @@ package graphql_test import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "time" "github.com/stretchr/testify/mock" @@ -24,7 +23,7 @@ func (m *mockAnalyticsService) IncrementTranslationCounter(ctx context.Context, return args.Error(0) } -func (m *mockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { +func (m *mockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { args := m.Called(ctx, workID, stats) return args.Error(0) } @@ -34,12 +33,12 @@ func (m *mockAnalyticsService) UpdateTranslationStats(ctx context.Context, trans return args.Error(0) } -func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { +func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { args := m.Called(ctx, workID) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*work.WorkStats), args.Error(1) + return args.Get(0).(*domain.WorkStats), args.Error(1) } func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { @@ -68,10 +67,10 @@ func (m *mockAnalyticsService) UpdateTrendingWorks(ctx context.Context, timePeri return args.Error(0) } -func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { args := m.Called(ctx, timePeriod, limit) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).([]*work.Work), args.Error(1) + return args.Get(0).([]*domain.Work), args.Error(1) } \ No newline at end of file diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index 2b9768d..1f97b58 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -17,7 +17,6 @@ import ( "tercul/internal/app/like" "tercul/internal/app/translation" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/observability" platform_auth "tercul/internal/platform/auth" platform_config "tercul/internal/platform/config" @@ -1002,8 +1001,8 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() { // Arrange work1 := s.CreateTestWork("Work 1", "en", "content") work2 := s.CreateTestWork("Work 2", "en", "content") - s.DB.Create(&work.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) - s.DB.Create(&work.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + 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.Analytics.UpdateTrending(context.Background())) // Act diff --git a/internal/adapters/graphql/like_repo_mock_test.go b/internal/adapters/graphql/like_repo_mock_test.go index 9c110a5..813d6bb 100644 --- a/internal/adapters/graphql/like_repo_mock_test.go +++ b/internal/adapters/graphql/like_repo_mock_test.go @@ -29,56 +29,108 @@ func (m *mockLikeRepository) Delete(ctx context.Context, id uint) error { return args.Error(0) } func (m *mockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, userID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } + func (m *mockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, workID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } + func (m *mockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, translationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } + func (m *mockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, commentID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } // Implement the rest of the BaseRepository methods as needed, or panic if they are not expected to be called. func (m *mockLikeRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { return m.Create(ctx, entity) } + func (m *mockLikeRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Like, error) { return m.GetByID(ctx, id) } + func (m *mockLikeRepository) Update(ctx context.Context, entity *domain.Like) error { args := m.Called(ctx, entity) return args.Error(0) } + func (m *mockLikeRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { return m.Update(ctx, entity) } + func (m *mockLikeRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return m.Delete(ctx, id) } + func (m *mockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { - panic("not implemented") + args := m.Called(ctx, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.PaginatedResult[domain.Like]), args.Error(1) } + func (m *mockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } -func (m *mockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { panic("not implemented") } + +func (m *mockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) +} + func (m *mockLikeRepository) Count(ctx context.Context) (int64, error) { - panic("not implemented") + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) } + func (m *mockLikeRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - panic("not implemented") + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) } + func (m *mockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { return m.GetByID(ctx, id) } + func (m *mockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { - panic("not implemented") + args := m.Called(ctx, batchSize, offset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Like), args.Error(1) } + func (m *mockLikeRepository) Exists(ctx context.Context, id uint) (bool, error) { - panic("not implemented") + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) } func (m *mockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } func (m *mockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index 93fa4f6..e74f90b 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -1,11 +1,8 @@ package graphql -// This file will be automatically regenerated based on the schema, any resolver implementations -// will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.78 - import ( "context" + "errors" "fmt" "strconv" "tercul/internal/adapters/graphql/model" @@ -20,8 +17,8 @@ import ( "tercul/internal/app/translation" "tercul/internal/app/user" "tercul/internal/domain" - "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" + "time" ) // Register is the resolver for the register field. @@ -95,11 +92,9 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput return nil, err } // Create domain model - workModel := &work.Work{ + workModel := &domain.Work{ Title: input.Name, TranslatableModel: domain.TranslatableModel{Language: input.Language}, - // Description: *input.Description, - // Other fields can be set here } // Call work service @@ -119,8 +114,6 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput } _, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, translationInput) if err != nil { - // If this fails, should we roll back the work creation? - // For now, just return the error. A transaction would be better. return nil, fmt.Errorf("failed to create initial translation: %w", err) } } @@ -146,7 +139,7 @@ func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input mode } // Create domain model - workModel := &work.Work{ + workModel := &domain.Work{ TranslatableModel: domain.TranslatableModel{ BaseModel: domain.BaseModel{ID: uint(workID)}, Language: input.Language, @@ -190,8 +183,6 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr return nil, err } - // The authorization is now handled inside the command, so we don't need a separate check here. - workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) @@ -202,23 +193,20 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr content = *input.Content } - // Call translation service using the new CreateOrUpdate command createInput := translation.CreateOrUpdateTranslationInput{ Title: input.Name, Content: content, Language: input.Language, TranslatableID: uint(workID), - TranslatableType: "works", // Assuming "works" for now, schema should be more generic + TranslatableType: "works", } createdTranslation, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, createInput) if err != nil { return nil, err } - // Increment translation count for the work go r.App.Analytics.IncrementWorkTranslationCount(context.Background(), uint(workID)) - // Convert to GraphQL model return &model.Translation{ ID: fmt.Sprintf("%d", createdTranslation.ID), Name: createdTranslation.Title, @@ -230,9 +218,6 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr // UpdateTranslation is the resolver for the updateTranslation field. func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) { - // This now acts as an "upsert" by calling the same command as CreateTranslation. - // The `id` of the translation is no longer needed, as the upsert logic - // relies on the parent (WorkID) and the language. if err := Validate(input); err != nil { return nil, err } @@ -247,7 +232,6 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp content = *input.Content } - // Call translation service using the new CreateOrUpdate command updateInput := translation.CreateOrUpdateTranslationInput{ Title: input.Name, Content: content, @@ -260,9 +244,8 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp return nil, err } - // Convert to GraphQL model return &model.Translation{ - ID: fmt.Sprintf("%d", updatedTranslation.ID), // Return the potentially new ID + ID: fmt.Sprintf("%d", updatedTranslation.ID), Name: updatedTranslation.Title, Language: updatedTranslation.Language, Content: &updatedTranslation.Content, @@ -365,7 +348,6 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI if err := Validate(input); err != nil { return nil, err } - // Call author service createInput := author.CreateAuthorInput{ Name: input.Name, } @@ -374,7 +356,6 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI return nil, err } - // Convert to GraphQL model return &model.Author{ ID: fmt.Sprintf("%d", createdAuthor.ID), Name: createdAuthor.Name, @@ -392,7 +373,6 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo return nil, fmt.Errorf("invalid author ID: %v", err) } - // Call author service updateInput := author.UpdateAuthorInput{ ID: uint(authorID), Name: input.Name, @@ -402,7 +382,6 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo return nil, err } - // Convert to GraphQL model return &model.Author{ ID: id, Name: updatedAuthor.Name, @@ -485,7 +464,6 @@ func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input mode return nil, err } - // Convert to GraphQL model return &model.User{ ID: fmt.Sprintf("%d", updatedUser.ID), Username: updatedUser.Username, @@ -518,13 +496,11 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, err // CreateCollection is the resolver for the createCollection field. func (r *mutationResolver) CreateCollection(ctx context.Context, input model.CollectionInput) (*model.Collection, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Call collection service createInput := collection.CreateCollectionInput{ Name: input.Name, UserID: userID, @@ -537,7 +513,6 @@ func (r *mutationResolver) CreateCollection(ctx context.Context, input model.Col return nil, err } - // Convert to GraphQL model return &model.Collection{ ID: fmt.Sprintf("%d", createdCollection.ID), Name: createdCollection.Name, @@ -550,19 +525,16 @@ func (r *mutationResolver) CreateCollection(ctx context.Context, input model.Col // UpdateCollection is the resolver for the updateCollection field. func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, input model.CollectionInput) (*model.Collection, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Parse collection ID collectionID, err := strconv.ParseUint(id, 10, 32) if err != nil { return nil, fmt.Errorf("invalid collection ID: %v", err) } - // Call collection service updateInput := collection.UpdateCollectionInput{ ID: uint(collectionID), Name: input.Name, @@ -576,7 +548,6 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu return nil, err } - // Convert to GraphQL model return &model.Collection{ ID: id, Name: updatedCollection.Name, @@ -589,19 +560,16 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu // DeleteCollection is the resolver for the deleteCollection field. func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (bool, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return false, fmt.Errorf("unauthorized") } - // Parse collection ID collectionID, err := strconv.ParseUint(id, 10, 32) if err != nil { return false, fmt.Errorf("invalid collection ID: %v", err) } - // Call collection service err = r.App.Collection.Commands.DeleteCollection(ctx, uint(collectionID), userID) if err != nil { return false, err @@ -612,13 +580,11 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo // AddWorkToCollection is the resolver for the addWorkToCollection field. func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID string, workID string) (*model.Collection, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Parse IDs collID, err := strconv.ParseUint(collectionID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid collection ID: %v", err) @@ -628,7 +594,6 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID return nil, fmt.Errorf("invalid work ID: %v", err) } - // Add work to collection addInput := collection.AddWorkToCollectionInput{ CollectionID: uint(collID), WorkID: uint(wID), @@ -639,13 +604,11 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID return nil, err } - // Fetch the updated collection to return it updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } - // Convert to GraphQL model return &model.Collection{ ID: collectionID, Name: updatedCollection.Name, @@ -655,13 +618,11 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID // RemoveWorkFromCollection is the resolver for the removeWorkFromCollection field. func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collectionID string, workID string) (*model.Collection, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Parse IDs collID, err := strconv.ParseUint(collectionID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid collection ID: %v", err) @@ -671,7 +632,6 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect return nil, fmt.Errorf("invalid work ID: %v", err) } - // Remove work from collection removeInput := collection.RemoveWorkFromCollectionInput{ CollectionID: uint(collID), WorkID: uint(wID), @@ -682,13 +642,11 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect return nil, err } - // Fetch the updated collection to return it updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) if err != nil { return nil, err } - // Convert to GraphQL model return &model.Collection{ ID: collectionID, Name: updatedCollection.Name, @@ -698,18 +656,15 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect // CreateComment is the resolver for the createComment field. func (r *mutationResolver) CreateComment(ctx context.Context, input model.CommentInput) (*model.Comment, error) { - // Custom validation if (input.WorkID == nil && input.TranslationID == nil) || (input.WorkID != nil && input.TranslationID != nil) { return nil, fmt.Errorf("must provide either workId or translationId, but not both") } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Create command input createInput := comment.CreateCommentInput{ Text: input.Text, UserID: userID, @@ -739,13 +694,11 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen createInput.ParentID = &pID } - // Call comment service createdComment, err := r.App.Comment.Commands.CreateComment(ctx, createInput) if err != nil { return nil, err } - // Increment analytics if createdComment.WorkID != nil { r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID) } @@ -753,7 +706,6 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID) } - // Convert to GraphQL model return &model.Comment{ ID: fmt.Sprintf("%d", createdComment.ID), Text: createdComment.Text, @@ -765,19 +717,16 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen // UpdateComment is the resolver for the updateComment field. func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input model.CommentInput) (*model.Comment, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Parse comment ID commentID, err := strconv.ParseUint(id, 10, 32) if err != nil { return nil, fmt.Errorf("invalid comment ID: %v", err) } - // Fetch the existing comment commentModel, err := r.App.Comment.Queries.Comment(ctx, uint(commentID)) if err != nil { return nil, err @@ -786,12 +735,10 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m return nil, fmt.Errorf("comment not found") } - // Check ownership if commentModel.UserID != userID { return nil, fmt.Errorf("unauthorized") } - // Call comment service updateInput := comment.UpdateCommentInput{ ID: uint(commentID), Text: input.Text, @@ -801,7 +748,6 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m return nil, err } - // Convert to GraphQL model return &model.Comment{ ID: id, Text: updatedComment.Text, @@ -813,19 +759,16 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m // DeleteComment is the resolver for the deleteComment field. func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return false, fmt.Errorf("unauthorized") } - // Parse comment ID commentID, err := strconv.ParseUint(id, 10, 32) if err != nil { return false, fmt.Errorf("invalid comment ID: %v", err) } - // Fetch the existing comment comment, err := r.App.Comment.Queries.Comment(ctx, uint(commentID)) if err != nil { return false, err @@ -834,12 +777,10 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, return false, fmt.Errorf("comment not found") } - // Check ownership if comment.UserID != userID { return false, fmt.Errorf("unauthorized") } - // Call comment repository err = r.App.Comment.Commands.DeleteComment(ctx, uint(commentID)) if err != nil { return false, err @@ -850,7 +791,6 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, // CreateLike is the resolver for the createLike field. func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput) (*model.Like, error) { - // Custom validation if (input.WorkID == nil && input.TranslationID == nil && input.CommentID == nil) || (input.WorkID != nil && input.TranslationID != nil) || (input.WorkID != nil && input.CommentID != nil) || @@ -858,13 +798,11 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("must provide exactly one of workId, translationId, or commentId") } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Create command input createInput := like.CreateLikeInput{ UserID: userID, } @@ -893,13 +831,11 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput createInput.CommentID = &cID } - // Call like service createdLike, err := r.App.Like.Commands.CreateLike(ctx, createInput) if err != nil { return nil, err } - // Increment analytics if createdLike.WorkID != nil { r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID) } @@ -907,7 +843,6 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID) } - // Convert to GraphQL model return &model.Like{ ID: fmt.Sprintf("%d", createdLike.ID), User: &model.User{ID: fmt.Sprintf("%d", userID)}, @@ -916,19 +851,16 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput // DeleteLike is the resolver for the deleteLike field. func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return false, fmt.Errorf("unauthorized") } - // Parse like ID likeID, err := strconv.ParseUint(id, 10, 32) if err != nil { return false, fmt.Errorf("invalid like ID: %v", err) } - // Fetch the existing like like, err := r.App.Like.Queries.Like(ctx, uint(likeID)) if err != nil { return false, err @@ -937,12 +869,10 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err return false, fmt.Errorf("like not found") } - // Check ownership if like.UserID != userID { return false, fmt.Errorf("unauthorized") } - // Call like service err = r.App.Like.Commands.DeleteLike(ctx, uint(likeID)) if err != nil { return false, err @@ -953,19 +883,16 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err // CreateBookmark is the resolver for the createBookmark field. func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.BookmarkInput) (*model.Bookmark, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Parse work ID workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid work ID: %v", err) } - // Create command input createInput := bookmark.CreateBookmarkInput{ UserID: userID, WorkID: uint(workID), @@ -974,16 +901,13 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm createInput.Name = *input.Name } - // Call bookmark service createdBookmark, err := r.App.Bookmark.Commands.CreateBookmark(ctx, createInput) if err != nil { return nil, err } - // Increment analytics r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID)) - // Convert to GraphQL model return &model.Bookmark{ ID: fmt.Sprintf("%d", createdBookmark.ID), Name: &createdBookmark.Name, @@ -994,19 +918,16 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm // DeleteBookmark is the resolver for the deleteBookmark field. func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return false, fmt.Errorf("unauthorized") } - // Parse bookmark ID bookmarkID, err := strconv.ParseUint(id, 10, 32) if err != nil { return false, fmt.Errorf("invalid bookmark ID: %v", err) } - // Fetch the existing bookmark bookmark, err := r.App.Bookmark.Queries.Bookmark(ctx, uint(bookmarkID)) if err != nil { return false, err @@ -1015,12 +936,10 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, return false, fmt.Errorf("bookmark not found") } - // Check ownership if bookmark.UserID != userID { return false, fmt.Errorf("unauthorized") } - // Call bookmark service err = r.App.Bookmark.Commands.DeleteBookmark(ctx, uint(bookmarkID)) if err != nil { return false, err @@ -1031,13 +950,11 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, // CreateContribution is the resolver for the createContribution field. func (r *mutationResolver) CreateContribution(ctx context.Context, input model.ContributionInput) (*model.Contribution, error) { - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { return nil, fmt.Errorf("unauthorized") } - // Convert GraphQL input to service input createInput := contribution.CreateContributionInput{ Name: input.Name, } @@ -1066,13 +983,11 @@ func (r *mutationResolver) CreateContribution(ctx context.Context, input model.C createInput.Status = "DRAFT" // Default status } - // Call contribution service createdContribution, err := r.App.Contribution.Commands.CreateContribution(ctx, createInput) if err != nil { return nil, err } - // Convert to GraphQL model return &model.Contribution{ ID: fmt.Sprintf("%d", createdContribution.ID), Name: createdContribution.Name, @@ -1085,57 +1000,242 @@ func (r *mutationResolver) CreateContribution(ctx context.Context, input model.C // UpdateContribution is the resolver for the updateContribution field. func (r *mutationResolver) UpdateContribution(ctx context.Context, id string, input model.ContributionInput) (*model.Contribution, error) { - panic(fmt.Errorf("not implemented: UpdateContribution - updateContribution")) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + contributionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid contribution ID", domain.ErrValidation) + } + + updateInput := contribution.UpdateContributionInput{ + ID: uint(contributionID), + UserID: userID, + Name: &input.Name, + } + + if input.Status != nil { + status := input.Status.String() + updateInput.Status = &status + } + + updatedContribution, err := r.App.Contribution.Commands.UpdateContribution(ctx, updateInput) + if err != nil { + return nil, err + } + + return &model.Contribution{ + ID: fmt.Sprintf("%d", updatedContribution.ID), + Name: updatedContribution.Name, + Status: model.ContributionStatus(updatedContribution.Status), + User: &model.User{ + ID: fmt.Sprintf("%d", updatedContribution.UserID), + }, + }, nil } // DeleteContribution is the resolver for the deleteContribution field. func (r *mutationResolver) DeleteContribution(ctx context.Context, id string) (bool, error) { - panic(fmt.Errorf("not implemented: DeleteContribution - deleteContribution")) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + contributionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return false, fmt.Errorf("%w: invalid contribution ID", domain.ErrValidation) + } + + err = r.App.Contribution.Commands.DeleteContribution(ctx, uint(contributionID), userID) + if err != nil { + return false, err + } + + return true, nil } // ReviewContribution is the resolver for the reviewContribution field. func (r *mutationResolver) ReviewContribution(ctx context.Context, id string, status model.ContributionStatus, feedback *string) (*model.Contribution, error) { - panic(fmt.Errorf("not implemented: ReviewContribution - reviewContribution")) + contributionID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid contribution ID", domain.ErrValidation) + } + + reviewInput := contribution.ReviewContributionInput{ + ID: uint(contributionID), + Status: status.String(), + Feedback: feedback, + } + + reviewedContribution, err := r.App.Contribution.Commands.ReviewContribution(ctx, reviewInput) + if err != nil { + return nil, err + } + + return &model.Contribution{ + ID: fmt.Sprintf("%d", reviewedContribution.ID), + Name: reviewedContribution.Name, + Status: model.ContributionStatus(reviewedContribution.Status), + User: &model.User{ + ID: fmt.Sprintf("%d", reviewedContribution.UserID), + }, + }, nil } // Logout is the resolver for the logout field. func (r *mutationResolver) Logout(ctx context.Context) (bool, error) { - panic(fmt.Errorf("not implemented: Logout - logout")) + err := r.App.Auth.Commands.Logout(ctx) + if err != nil { + return false, err + } + return true, nil } // RefreshToken is the resolver for the refreshToken field. func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) { - panic(fmt.Errorf("not implemented: RefreshToken - refreshToken")) + authResponse, err := r.App.Auth.Commands.RefreshToken(ctx) + if err != nil { + return nil, err + } + + return &model.AuthPayload{ + Token: authResponse.Token, + User: &model.User{ + ID: fmt.Sprintf("%d", authResponse.User.ID), + Username: authResponse.User.Username, + Email: authResponse.User.Email, + FirstName: &authResponse.User.FirstName, + LastName: &authResponse.User.LastName, + DisplayName: &authResponse.User.DisplayName, + Role: model.UserRole(authResponse.User.Role), + Verified: authResponse.User.Verified, + Active: authResponse.User.Active, + }, + }, nil } // ForgotPassword is the resolver for the forgotPassword field. func (r *mutationResolver) ForgotPassword(ctx context.Context, email string) (bool, error) { - panic(fmt.Errorf("not implemented: ForgotPassword - forgotPassword")) + err := r.App.Auth.Commands.ForgotPassword(ctx, email) + if err != nil { + return true, nil + } + return true, nil } // ResetPassword is the resolver for the resetPassword field. func (r *mutationResolver) ResetPassword(ctx context.Context, token string, newPassword string) (bool, error) { - panic(fmt.Errorf("not implemented: ResetPassword - resetPassword")) + resetInput := auth.ResetPasswordInput{ + Token: token, + NewPassword: newPassword, + } + err := r.App.Auth.Commands.ResetPassword(ctx, resetInput) + if err != nil { + return false, err + } + return true, nil } // VerifyEmail is the resolver for the verifyEmail field. func (r *mutationResolver) VerifyEmail(ctx context.Context, token string) (bool, error) { - panic(fmt.Errorf("not implemented: VerifyEmail - verifyEmail")) + err := r.App.Auth.Commands.VerifyEmail(ctx, token) + if err != nil { + return false, err + } + return true, nil } // ResendVerificationEmail is the resolver for the resendVerificationEmail field. func (r *mutationResolver) ResendVerificationEmail(ctx context.Context, email string) (bool, error) { - panic(fmt.Errorf("not implemented: ResendVerificationEmail - resendVerificationEmail")) + err := r.App.Auth.Commands.ResendVerificationEmail(ctx, email) + if err != nil { + return true, nil + } + return true, nil } // UpdateProfile is the resolver for the updateProfile field. func (r *mutationResolver) UpdateProfile(ctx context.Context, input model.UserInput) (*model.User, error) { - panic(fmt.Errorf("not implemented: UpdateProfile - updateProfile")) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + updateInput := user.UpdateUserInput{ + ID: userID, + FirstName: input.FirstName, + LastName: input.LastName, + DisplayName: input.DisplayName, + Bio: input.Bio, + AvatarURL: input.AvatarURL, + } + + if input.CountryID != nil { + countryID, err := strconv.ParseUint(*input.CountryID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid country ID: %v", err) + } + uid := uint(countryID) + updateInput.CountryID = &uid + } + if input.CityID != nil { + cityID, err := strconv.ParseUint(*input.CityID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid city ID: %v", err) + } + uid := uint(cityID) + updateInput.CityID = &uid + } + if input.AddressID != nil { + addressID, err := strconv.ParseUint(*input.AddressID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid address ID: %v", err) + } + uid := uint(addressID) + updateInput.AddressID = &uid + } + + updatedUser, err := r.App.User.Commands.UpdateUser(ctx, updateInput) + if err != nil { + return nil, err + } + + return &model.User{ + ID: fmt.Sprintf("%d", updatedUser.ID), + Username: updatedUser.Username, + Email: updatedUser.Email, + FirstName: &updatedUser.FirstName, + LastName: &updatedUser.LastName, + DisplayName: &updatedUser.DisplayName, + Bio: &updatedUser.Bio, + AvatarURL: &updatedUser.AvatarURL, + Role: model.UserRole(updatedUser.Role), + Verified: updatedUser.Verified, + Active: updatedUser.Active, + }, nil } // ChangePassword is the resolver for the changePassword field. func (r *mutationResolver) ChangePassword(ctx context.Context, currentPassword string, newPassword string) (bool, error) { - panic(fmt.Errorf("not implemented: ChangePassword - changePassword")) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return false, domain.ErrUnauthorized + } + + changeInput := auth.ChangePasswordInput{ + UserID: userID, + CurrentPassword: currentPassword, + NewPassword: newPassword, + } + + err := r.App.Auth.Commands.ChangePassword(ctx, changeInput) + if err != nil { + return false, err + } + + return true, nil } // Work is the resolver for the work field. @@ -1145,32 +1245,28 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error return nil, fmt.Errorf("invalid work ID: %v", err) } - workRecord, err := r.App.Work.Queries.GetWorkByID(ctx, uint(workID)) + workDTO, err := r.App.Work.Queries.GetWorkByID(ctx, uint(workID)) if err != nil { return nil, err } - if workRecord == nil { + if workDTO == nil { return nil, nil } - // Increment view count in the background go r.App.Analytics.IncrementWorkViews(context.Background(), uint(workID)) - content := r.resolveWorkContent(ctx, workRecord.ID, workRecord.Language) + content := r.resolveWorkContent(ctx, workDTO.ID, workDTO.Language) return &model.Work{ ID: id, - Name: workRecord.Title, - Language: workRecord.Language, + Name: workDTO.Title, + Language: workDTO.Language, Content: content, }, nil } // Works is the resolver for the works field. func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, language *string, authorID *string, categoryID *string, tagID *string, search *string) ([]*model.Work, error) { - // This resolver has complex logic that should be moved to the application layer. - // For now, I will just call the ListWorks query. - // A proper implementation would have specific query methods for each filter. page := 1 pageSize := 20 if limit != nil { @@ -1185,7 +1281,6 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, return nil, err } - // Convert to GraphQL model var result []*model.Work for _, w := range paginatedResult.Items { content := r.resolveWorkContent(ctx, w.ID, w.Language) @@ -1214,10 +1309,8 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran return nil, nil } - // Increment view count in the background go r.App.Analytics.IncrementTranslationViews(context.Background(), uint(translationID)) - // Convert to GraphQL model return &model.Translation{ ID: id, Name: translationRecord.Title, @@ -1229,7 +1322,36 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran // Translations is the resolver for the translations field. func (r *queryResolver) Translations(ctx context.Context, workID string, language *string, limit *int32, offset *int32) ([]*model.Translation, error) { - panic(fmt.Errorf("not implemented: Translations - translations")) + wID, err := strconv.ParseUint(workID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) + } + + page := 1 + pageSize := 20 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + + paginatedResult, err := r.App.Translation.Queries.ListTranslations(ctx, uint(wID), language, page, pageSize) + if err != nil { + return nil, err + } + + var result []*model.Translation + for _, t := range paginatedResult.Items { + result = append(result, &model.Translation{ + ID: fmt.Sprintf("%d", t.ID), + Name: t.Title, + Language: t.Language, + Content: &t.Content, + WorkID: workID, + }) + } + return result, nil } // Book is the resolver for the book field. @@ -1279,7 +1401,34 @@ func (r *queryResolver) Books(ctx context.Context, limit *int32, offset *int32) // Author is the resolver for the author field. func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) { - panic(fmt.Errorf("not implemented: Author - author")) + authorID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid author ID", domain.ErrValidation) + } + + authorRecord, err := r.App.Author.Queries.Author(ctx, uint(authorID)) + if err != nil { + return nil, err + } + if authorRecord == nil { + return nil, nil + } + + var bio *string + authorWithTranslations, err := r.App.Author.Queries.AuthorWithTranslations(ctx, authorRecord.ID) + if err == nil && authorWithTranslations != nil { + biography, err := r.App.Localization.Queries.GetAuthorBiography(ctx, authorRecord.ID, authorRecord.Language) + if err == nil && biography != "" { + bio = &biography + } + } + + return &model.Author{ + ID: fmt.Sprintf("%d", authorRecord.ID), + Name: authorRecord.Name, + Language: authorRecord.Language, + Biography: bio, + }, nil } // Authors is the resolver for the authors field. @@ -1302,7 +1451,6 @@ func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32 return nil, err } - // Convert to GraphQL model; resolve biography var result []*model.Author for _, a := range authors { var bio *string @@ -1327,17 +1475,82 @@ func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32 // User is the resolver for the user field. func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) { - panic(fmt.Errorf("not implemented: User - user")) + userID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation) + } + + userRecord, err := r.App.User.Queries.User(ctx, uint(userID)) + if err != nil { + return nil, err + } + if userRecord == nil { + return nil, nil + } + + return &model.User{ + ID: fmt.Sprintf("%d", userRecord.ID), + Username: userRecord.Username, + Email: userRecord.Email, + FirstName: &userRecord.FirstName, + LastName: &userRecord.LastName, + DisplayName: &userRecord.DisplayName, + Bio: &userRecord.Bio, + AvatarURL: &userRecord.AvatarURL, + Role: model.UserRole(userRecord.Role), + Verified: userRecord.Verified, + Active: userRecord.Active, + }, nil } // UserByEmail is the resolver for the userByEmail field. func (r *queryResolver) UserByEmail(ctx context.Context, email string) (*model.User, error) { - panic(fmt.Errorf("not implemented: UserByEmail - userByEmail")) + userRecord, err := r.App.User.Queries.UserByEmail(ctx, email) + if err != nil { + return nil, err + } + if userRecord == nil { + return nil, nil + } + + return &model.User{ + ID: fmt.Sprintf("%d", userRecord.ID), + Username: userRecord.Username, + Email: userRecord.Email, + FirstName: &userRecord.FirstName, + LastName: &userRecord.LastName, + DisplayName: &userRecord.DisplayName, + Bio: &userRecord.Bio, + AvatarURL: &userRecord.AvatarURL, + Role: model.UserRole(userRecord.Role), + Verified: userRecord.Verified, + Active: userRecord.Active, + }, nil } // UserByUsername is the resolver for the userByUsername field. func (r *queryResolver) UserByUsername(ctx context.Context, username string) (*model.User, error) { - panic(fmt.Errorf("not implemented: UserByUsername - userByUsername")) + userRecord, err := r.App.User.Queries.UserByUsername(ctx, username) + if err != nil { + return nil, err + } + if userRecord == nil { + return nil, nil + } + + return &model.User{ + ID: fmt.Sprintf("%d", userRecord.ID), + Username: userRecord.Username, + Email: userRecord.Email, + FirstName: &userRecord.FirstName, + LastName: &userRecord.LastName, + DisplayName: &userRecord.DisplayName, + Bio: &userRecord.Bio, + AvatarURL: &userRecord.AvatarURL, + Role: model.UserRole(userRecord.Role), + Verified: userRecord.Verified, + Active: userRecord.Active, + }, nil } // Users is the resolver for the users field. @@ -1346,7 +1559,6 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, var err error if role != nil { - // Convert GraphQL role to model role var modelRole domain.UserRole switch *role { case model.UserRoleReader: @@ -1371,10 +1583,8 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, return nil, err } - // Convert to GraphQL model var result []*model.User for _, u := range users { - // Convert model role to GraphQL role var graphqlRole model.UserRole switch u.Role { case domain.UserRoleReader: @@ -1404,22 +1614,174 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, // Me is the resolver for the me field. func (r *queryResolver) Me(ctx context.Context) (*model.User, error) { - panic(fmt.Errorf("not implemented: Me - me")) + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + userRecord, err := r.App.User.Queries.User(ctx, userID) + if err != nil { + return nil, err + } + if userRecord == nil { + return nil, domain.ErrUserNotFound + } + + return &model.User{ + ID: fmt.Sprintf("%d", userRecord.ID), + Username: userRecord.Username, + Email: userRecord.Email, + FirstName: &userRecord.FirstName, + LastName: &userRecord.LastName, + DisplayName: &userRecord.DisplayName, + Bio: &userRecord.Bio, + AvatarURL: &userRecord.AvatarURL, + Role: model.UserRole(userRecord.Role), + Verified: userRecord.Verified, + Active: userRecord.Active, + }, nil } // UserProfile is the resolver for the userProfile field. func (r *queryResolver) UserProfile(ctx context.Context, userID string) (*model.UserProfile, error) { - panic(fmt.Errorf("not implemented: UserProfile - userProfile")) + uID, err := strconv.ParseUint(userID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation) + } + + profile, err := r.App.User.Queries.UserProfile(ctx, uint(uID)) + if err != nil { + if errors.Is(err, domain.ErrEntityNotFound) { + return nil, nil + } + return nil, err + } + if profile == nil { + return nil, nil + } + + user, err := r.App.User.Queries.User(ctx, uint(uID)) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("user not found for profile %d", profile.ID) + } + + return &model.UserProfile{ + ID: fmt.Sprintf("%d", profile.ID), + UserID: userID, + User: &model.User{ + ID: fmt.Sprintf("%d", user.ID), + Username: user.Username, + Email: user.Email, + FirstName: &user.FirstName, + LastName: &user.LastName, + DisplayName: &user.DisplayName, + Bio: &user.Bio, + AvatarURL: &user.AvatarURL, + Role: model.UserRole(user.Role), + Verified: user.Verified, + Active: user.Active, + }, + PhoneNumber: &profile.PhoneNumber, + Website: &profile.Website, + Twitter: &profile.Twitter, + Facebook: &profile.Facebook, + LinkedIn: &profile.LinkedIn, + Github: &profile.Github, + }, nil } // Collection is the resolver for the collection field. func (r *queryResolver) Collection(ctx context.Context, id string) (*model.Collection, error) { - panic(fmt.Errorf("not implemented: Collection - collection")) + collID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid collection ID", domain.ErrValidation) + } + + collectionRecord, err := r.App.Collection.Queries.Collection(ctx, uint(collID)) + if err != nil { + return nil, err + } + if collectionRecord == nil { + return nil, nil + } + + workRecords, err := r.App.Work.Queries.ListByCollectionID(ctx, uint(collID)) + if err != nil { + return nil, err + } + + var works []*model.Work + for _, w := range workRecords { + content := r.resolveWorkContent(ctx, w.ID, w.Language) + works = append(works, &model.Work{ + ID: fmt.Sprintf("%d", w.ID), + Name: w.Title, + Language: w.Language, + Content: content, + }) + } + + return &model.Collection{ + ID: fmt.Sprintf("%d", collectionRecord.ID), + Name: collectionRecord.Name, + Description: &collectionRecord.Description, + Works: works, + User: &model.User{ + ID: fmt.Sprintf("%d", collectionRecord.UserID), + }, + }, nil } // Collections is the resolver for the collections field. func (r *queryResolver) Collections(ctx context.Context, userID *string, limit *int32, offset *int32) ([]*model.Collection, error) { - panic(fmt.Errorf("not implemented: Collections - collections")) + var collectionRecords []domain.Collection + var err error + + if userID != nil { + uID, err := strconv.ParseUint(*userID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation) + } + collectionRecords, err = r.App.Collection.Queries.CollectionsByUserID(ctx, uint(uID)) + } else { + collectionRecords, err = r.App.Collection.Queries.PublicCollections(ctx) + } + + if err != nil { + return nil, err + } + + start := 0 + if offset != nil { + start = int(*offset) + } + end := len(collectionRecords) + if limit != nil { + end = start + int(*limit) + if end > len(collectionRecords) { + end = len(collectionRecords) + } + } + if start > len(collectionRecords) { + start = len(collectionRecords) + } + paginatedRecords := collectionRecords[start:end] + + var result []*model.Collection + for _, c := range paginatedRecords { + result = append(result, &model.Collection{ + ID: fmt.Sprintf("%d", c.ID), + Name: c.Name, + Description: &c.Description, + User: &model.User{ + ID: fmt.Sprintf("%d", c.UserID), + }, + }) + } + return result, nil } // Tag is the resolver for the tag field. @@ -1447,7 +1809,6 @@ func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) ( return nil, err } - // Convert to GraphQL model var result []*model.Tag for _, t := range tags { result = append(result, &model.Tag{ @@ -1487,7 +1848,6 @@ func (r *queryResolver) Categories(ctx context.Context, limit *int32, offset *in return nil, err } - // Convert to GraphQL model var result []*model.Category for _, c := range categories { result = append(result, &model.Category{ @@ -1501,17 +1861,162 @@ func (r *queryResolver) Categories(ctx context.Context, limit *int32, offset *in // Comment is the resolver for the comment field. func (r *queryResolver) Comment(ctx context.Context, id string) (*model.Comment, error) { - panic(fmt.Errorf("not implemented: Comment - comment")) + cID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid comment ID", domain.ErrValidation) + } + + commentRecord, err := r.App.Comment.Queries.Comment(ctx, uint(cID)) + if err != nil { + return nil, err + } + if commentRecord == nil { + return nil, nil + } + + return &model.Comment{ + ID: fmt.Sprintf("%d", commentRecord.ID), + Text: commentRecord.Text, + User: &model.User{ + ID: fmt.Sprintf("%d", commentRecord.UserID), + }, + }, nil } // Comments is the resolver for the comments field. func (r *queryResolver) Comments(ctx context.Context, workID *string, translationID *string, userID *string, limit *int32, offset *int32) ([]*model.Comment, error) { - panic(fmt.Errorf("not implemented: Comments - comments")) + var commentRecords []domain.Comment + var err error + + if workID != nil { + wID, err := strconv.ParseUint(*workID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) + } + commentRecords, err = r.App.Comment.Queries.CommentsByWorkID(ctx, uint(wID)) + } else if translationID != nil { + tID, err := strconv.ParseUint(*translationID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid translation ID", domain.ErrValidation) + } + commentRecords, err = r.App.Comment.Queries.CommentsByTranslationID(ctx, uint(tID)) + } else if userID != nil { + uID, err := strconv.ParseUint(*userID, 10, 32) + if err != nil { + return nil, fmt.Errorf("%w: invalid user ID", domain.ErrValidation) + } + commentRecords, err = r.App.Comment.Queries.CommentsByUserID(ctx, uint(uID)) + } else { + commentRecords, err = r.App.Comment.Queries.Comments(ctx) + } + + if err != nil { + return nil, err + } + + start := 0 + if offset != nil { + start = int(*offset) + } + end := len(commentRecords) + if limit != nil { + end = start + int(*limit) + if end > len(commentRecords) { + end = len(commentRecords) + } + } + if start > len(commentRecords) { + start = len(commentRecords) + } + paginatedRecords := commentRecords[start:end] + + var result []*model.Comment + for _, c := range paginatedRecords { + result = append(result, &model.Comment{ + ID: fmt.Sprintf("%d", c.ID), + Text: c.Text, + User: &model.User{ + ID: fmt.Sprintf("%d", c.UserID), + }, + }) + } + return result, nil } // Search is the resolver for the search field. func (r *queryResolver) Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error) { - panic(fmt.Errorf("not implemented: Search - search")) + page := 1 + pageSize := 20 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + + var searchFilters domain.SearchFilters + if filters != nil { + searchFilters.Languages = filters.Languages + searchFilters.Categories = filters.Categories + searchFilters.Tags = filters.Tags + searchFilters.Authors = filters.Authors + + if filters.DateFrom != nil { + t, err := time.Parse(time.RFC3339, *filters.DateFrom) + if err != nil { + return nil, fmt.Errorf("invalid DateFrom format: %w", err) + } + searchFilters.DateFrom = &t + } + if filters.DateTo != nil { + t, err := time.Parse(time.RFC3339, *filters.DateTo) + if err != nil { + return nil, fmt.Errorf("invalid DateTo format: %w", err) + } + searchFilters.DateTo = &t + } + } + + results, err := r.App.Search.Search(ctx, query, page, pageSize, searchFilters) + if err != nil { + return nil, err + } + + var works []*model.Work + for _, w := range results.Works { + works = append(works, &model.Work{ + ID: fmt.Sprintf("%d", w.ID), + Name: w.Title, + Language: w.Language, + }) + } + + var translations []*model.Translation + for _, t := range results.Translations { + translations = append(translations, &model.Translation{ + ID: fmt.Sprintf("%d", t.ID), + Name: t.Title, + Language: t.Language, + Content: &t.Content, + WorkID: fmt.Sprintf("%d", t.TranslatableID), + }) + } + + var authors []*model.Author + for _, a := range results.Authors { + authors = append(authors, &model.Author{ + ID: fmt.Sprintf("%d", a.ID), + Name: a.Name, + Language: a.Language, + }) + } + + return &model.SearchResults{ + Works: works, + Translations: translations, + Authors: authors, + Total: int32(results.Total), + }, nil } // TrendingWorks is the resolver for the trendingWorks field. @@ -1550,4 +2055,4 @@ func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type mutationResolver struct{ *Resolver } -type queryResolver struct{ *Resolver } +type queryResolver struct{ *Resolver } \ No newline at end of file diff --git a/internal/adapters/graphql/work_repo_mock_test.go b/internal/adapters/graphql/work_repo_mock_test.go index 4fdbe07..5c7f011 100644 --- a/internal/adapters/graphql/work_repo_mock_test.go +++ b/internal/adapters/graphql/work_repo_mock_test.go @@ -3,7 +3,6 @@ package graphql_test import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "github.com/stretchr/testify/mock" "gorm.io/gorm" @@ -14,28 +13,28 @@ type mockWorkRepository struct { mock.Mock } -func (m *mockWorkRepository) Create(ctx context.Context, entity *work.Work) error { +func (m *mockWorkRepository) Create(ctx context.Context, entity *domain.Work) error { args := m.Called(ctx, entity) return args.Error(0) } -func (m *mockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *mockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return m.Create(ctx, entity) } -func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*work.Work), args.Error(1) + return args.Get(0).(*domain.Work), args.Error(1) } -func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return m.GetByID(ctx, id) } -func (m *mockWorkRepository) Update(ctx context.Context, entity *work.Work) error { +func (m *mockWorkRepository) Update(ctx context.Context, entity *domain.Work) error { args := m.Called(ctx, entity) return args.Error(0) } -func (m *mockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *mockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return m.Update(ctx, entity) } func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error { @@ -45,25 +44,44 @@ func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error { func (m *mockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return m.Delete(ctx, id) } -func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") +func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) } -func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { - panic("not implemented") +func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { + args := m.Called(ctx, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) +} +func (m *mockWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } -func (m *mockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { panic("not implemented") } func (m *mockWorkRepository) Count(ctx context.Context) (int64, error) { args := m.Called(ctx) return args.Get(0).(int64), args.Error(1) } func (m *mockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - panic("not implemented") + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) } -func (m *mockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { +func (m *mockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { return m.GetByID(ctx, id) } -func (m *mockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { - panic("not implemented") +func (m *mockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { + args := m.Called(ctx, batchSize, offset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } func (m *mockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { args := m.Called(ctx, id) @@ -73,37 +91,66 @@ func (m *mockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { re func (m *mockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } -func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { - panic("not implemented") + +func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { + args := m.Called(ctx, title) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } -func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { - panic("not implemented") +func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { + args := m.Called(ctx, authorID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } -func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { - panic("not implemented") +func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { + args := m.Called(ctx, categoryID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } -func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") +func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, language, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) } -func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*work.Work), args.Error(1) + return args.Get(0).(*domain.Work), args.Error(1) } -func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - panic("not implemented") +func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) } func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { args := m.Called(ctx, workID, authorID) return args.Bool(0), args.Error(1) } -func (m *mockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) { return m.GetByID(ctx, id) } -func (m *mockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) { return m.GetByID(ctx, id) +} + +func (m *mockWorkRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { + args := m.Called(ctx, collectionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) } \ No newline at end of file diff --git a/internal/app/analytics/interfaces.go b/internal/app/analytics/interfaces.go index aec9748..cc39173 100644 --- a/internal/app/analytics/interfaces.go +++ b/internal/app/analytics/interfaces.go @@ -3,7 +3,6 @@ package analytics import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "time" ) @@ -11,12 +10,12 @@ import ( type Repository interface { IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error - UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error + UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error - GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) + GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error - GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) + GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) } \ No newline at end of file diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index b4c166c..87bbcb2 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -7,7 +7,6 @@ import ( "sort" "strings" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/jobs/linguistics" "tercul/internal/platform/log" "time" @@ -29,7 +28,7 @@ type Service interface { DecrementTranslationLikes(ctx context.Context, translationID uint) error IncrementTranslationComments(ctx context.Context, translationID uint) error IncrementTranslationShares(ctx context.Context, translationID uint) error - GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) + GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) UpdateWorkReadingTime(ctx context.Context, workID uint) error @@ -40,19 +39,19 @@ type Service interface { UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error UpdateTrending(ctx context.Context) error - GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) + GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) } type service struct { repo Repository analysisRepo linguistics.AnalysisRepository translationRepo domain.TranslationRepository - workRepo work.WorkRepository + workRepo domain.WorkRepository sentimentProvider linguistics.SentimentProvider tracer trace.Tracer } -func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo work.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { +func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { return &service{ repo: repo, analysisRepo: analysisRepo, @@ -135,7 +134,7 @@ func (s *service) IncrementTranslationShares(ctx context.Context, translationID return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) } -func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { +func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { ctx, span := s.tracer.Start(ctx, "GetOrCreateWorkStats") defer span.End() return s.repo.GetOrCreateWorkStats(ctx, workID) @@ -309,7 +308,7 @@ func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventTy return s.repo.UpdateUserEngagement(ctx, engagement) } -func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { +func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { ctx, span := s.tracer.Start(ctx, "GetTrendingWorks") defer span.End() return s.repo.GetTrendingWorks(ctx, timePeriod, limit) diff --git a/internal/app/analytics/service_test.go b/internal/app/analytics/service_test.go index 11941da..ea45928 100644 --- a/internal/app/analytics/service_test.go +++ b/internal/app/analytics/service_test.go @@ -7,7 +7,6 @@ import ( "tercul/internal/app/analytics" "tercul/internal/data/sql" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/jobs/linguistics" "tercul/internal/platform/config" "tercul/internal/testutil" @@ -243,8 +242,8 @@ func (s *AnalyticsServiceTestSuite) TestUpdateTrending() { // Arrange work1 := s.CreateTestWork("Work 1", "en", "content") work2 := s.CreateTestWork("Work 2", "en", "content") - s.DB.Create(&work.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) - s.DB.Create(&work.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + 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}) // Act err := s.service.UpdateTrending(context.Background()) diff --git a/internal/app/app.go b/internal/app/app.go index 1c91e5e..eccedf6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,21 +13,19 @@ import ( "tercul/internal/app/contribution" "tercul/internal/app/like" "tercul/internal/app/localization" + appsearch "tercul/internal/app/search" "tercul/internal/app/tag" "tercul/internal/app/translation" "tercul/internal/app/user" "tercul/internal/app/work" "tercul/internal/domain" - auth_domain "tercul/internal/domain/auth" - localization_domain "tercul/internal/domain/localization" - "tercul/internal/domain/search" - work_domain "tercul/internal/domain/work" + domainsearch "tercul/internal/domain/search" platform_auth "tercul/internal/platform/auth" ) // Dependencies holds all external dependencies for the application. type Dependencies struct { - WorkRepo work_domain.WorkRepository + WorkRepo domain.WorkRepository UserRepo domain.UserRepository AuthorRepo domain.AuthorRepository TranslationRepo domain.TranslationRepository @@ -43,10 +41,11 @@ type Dependencies struct { CopyrightRepo domain.CopyrightRepository MonetizationRepo domain.MonetizationRepository ContributionRepo domain.ContributionRepository + UserProfileRepo domain.UserProfileRepository AnalyticsRepo analytics.Repository - AuthRepo auth_domain.AuthRepository - LocalizationRepo localization_domain.LocalizationRepository - SearchClient search.SearchClient + AuthRepo domain.AuthRepository + LocalizationRepo domain.LocalizationRepository + SearchClient domainsearch.SearchClient AnalyticsService analytics.Service JWTManager platform_auth.JWTManagement } @@ -68,6 +67,7 @@ type Application struct { Auth *auth.Service Authz *authz.Service Work *work.Service + Search appsearch.Service Analytics analytics.Service } @@ -84,10 +84,11 @@ func NewApplication(deps Dependencies) *Application { likeService := like.NewService(deps.LikeRepo, deps.AnalyticsService) tagService := tag.NewService(deps.TagRepo) translationService := translation.NewService(deps.TranslationRepo, authzService) - userService := user.NewService(deps.UserRepo, authzService) + userService := user.NewService(deps.UserRepo, authzService, deps.UserProfileRepo) localizationService := localization.NewService(deps.LocalizationRepo) authService := auth.NewService(deps.UserRepo, deps.JWTManager) workService := work.NewService(deps.WorkRepo, deps.SearchClient, authzService) + searchService := appsearch.NewService(deps.SearchClient, localizationService) return &Application{ Author: authorService, @@ -105,6 +106,7 @@ func NewApplication(deps Dependencies) *Application { Auth: authService, Authz: authzService, Work: workService, + Search: searchService, Analytics: deps.AnalyticsService, } } \ No newline at end of file diff --git a/internal/app/auth/commands.go b/internal/app/auth/commands.go index d32544a..d2f8212 100644 --- a/internal/app/auth/commands.go +++ b/internal/app/auth/commands.go @@ -111,6 +111,96 @@ func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthRespon }, nil } +// Logout invalidates a user's session. +func (c *AuthCommands) Logout(ctx context.Context) error { + // Implementation depends on how sessions are managed (e.g., blacklisting tokens). + // For now, this is a placeholder. + return nil +} + +// RefreshToken generates a new token for an authenticated user. +func (c *AuthCommands) RefreshToken(ctx context.Context) (*AuthResponse, error) { + userID, ok := auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + user, err := c.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, domain.ErrUserNotFound + } + + token, err := c.jwtManager.GenerateToken(user) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &AuthResponse{ + Token: token, + User: user, + ExpiresAt: time.Now().Add(24 * time.Hour), + }, nil +} + +// ForgotPassword initiates the password reset process for a user. +func (c *AuthCommands) ForgotPassword(ctx context.Context, email string) error { + // In a real application, this would generate a reset token and send an email. + // For now, this is a placeholder. + return nil +} + +// ResetPasswordInput represents the input for resetting a password. +type ResetPasswordInput struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +// ResetPassword resets a user's password using a reset token. +func (c *AuthCommands) ResetPassword(ctx context.Context, input ResetPasswordInput) error { + // In a real application, this would validate the token, find the user, and update the password. + // For now, this is a placeholder. + return nil +} + +// VerifyEmail verifies a user's email address using a verification token. +func (c *AuthCommands) VerifyEmail(ctx context.Context, token string) error { + // In a real application, this would validate the token and mark the user's email as verified. + // For now, this is a placeholder. + return nil +} + +// ResendVerificationEmail resends the email verification link to a user. +func (c *AuthCommands) ResendVerificationEmail(ctx context.Context, email string) error { + // In a real application, this would generate a new verification token and send it. + // For now, this is a placeholder. + return nil +} + +// ChangePasswordInput represents the input for changing a password. +type ChangePasswordInput struct { + UserID uint + CurrentPassword string + NewPassword string +} + +// ChangePassword allows an authenticated user to change their password. +func (c *AuthCommands) ChangePassword(ctx context.Context, input ChangePasswordInput) error { + user, err := c.userRepo.GetByID(ctx, input.UserID) + if err != nil { + return domain.ErrUserNotFound + } + + if !user.CheckPassword(input.CurrentPassword) { + return ErrInvalidCredentials + } + + if err := user.SetPassword(input.NewPassword); err != nil { + return err + } + + return c.userRepo.Update(ctx, user) +} + // Register creates a new user account func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) { ctx, span := c.tracer.Start(ctx, "Register") diff --git a/internal/app/authz/authz.go b/internal/app/authz/authz.go index 0b59477..4c65cb2 100644 --- a/internal/app/authz/authz.go +++ b/internal/app/authz/authz.go @@ -3,18 +3,17 @@ package authz import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" ) // Service provides authorization checks for the application. type Service struct { - workRepo work.WorkRepository + workRepo domain.WorkRepository translationRepo domain.TranslationRepository } // NewService creates a new authorization service. -func NewService(workRepo work.WorkRepository, translationRepo domain.TranslationRepository) *Service { +func NewService(workRepo domain.WorkRepository, translationRepo domain.TranslationRepository) *Service { return &Service{ workRepo: workRepo, translationRepo: translationRepo, @@ -23,7 +22,7 @@ func NewService(workRepo work.WorkRepository, translationRepo domain.Translation // CanEditWork checks if a user has permission to edit a work. // For now, we'll implement a simple rule: only an admin or the work's author can edit it. -func (s *Service) CanEditWork(ctx context.Context, userID uint, work *work.Work) (bool, error) { +func (s *Service) CanEditWork(ctx context.Context, userID uint, work *domain.Work) (bool, error) { claims, ok := platform_auth.GetClaimsFromContext(ctx) if !ok { return false, domain.ErrUnauthorized diff --git a/internal/app/contribution/commands.go b/internal/app/contribution/commands.go index f0cb956..664aa60 100644 --- a/internal/app/contribution/commands.go +++ b/internal/app/contribution/commands.go @@ -51,5 +51,88 @@ func (c *Commands) CreateContribution(ctx context.Context, input CreateContribut return nil, err } + return contribution, nil +} + +// UpdateContributionInput represents the input for updating a contribution. +type UpdateContributionInput struct { + ID uint + UserID uint + Name *string + Status *string +} + +// UpdateContribution updates an existing contribution. +func (c *Commands) UpdateContribution(ctx context.Context, input UpdateContributionInput) (*domain.Contribution, error) { + contribution, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + // Authorization check: only the user who created the contribution can update it. + if contribution.UserID != input.UserID { + return nil, domain.ErrForbidden + } + + if input.Name != nil { + contribution.Name = *input.Name + } + if input.Status != nil { + contribution.Status = *input.Status + } + + if err := c.repo.Update(ctx, contribution); err != nil { + return nil, err + } + + return contribution, nil +} + +// DeleteContribution deletes a contribution. +func (c *Commands) DeleteContribution(ctx context.Context, contributionID uint, userID uint) error { + contribution, err := c.repo.GetByID(ctx, contributionID) + if err != nil { + return err + } + + // Authorization check: only the user who created the contribution can delete it. + if contribution.UserID != userID { + return domain.ErrForbidden + } + + return c.repo.Delete(ctx, contributionID) +} + +// ReviewContributionInput represents the input for reviewing a contribution. +type ReviewContributionInput struct { + ID uint + Status string + Feedback *string +} + +// ReviewContribution reviews a contribution, updating its status and adding feedback. +func (c *Commands) ReviewContribution(ctx context.Context, input ReviewContributionInput) (*domain.Contribution, error) { + // Authorization check: for now, let's assume only admins/editors/reviewers can review. + claims, ok := platform_auth.GetClaimsFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + if claims.Role != string(domain.UserRoleAdmin) && claims.Role != string(domain.UserRoleEditor) && claims.Role != string(domain.UserRoleReviewer) { + return nil, domain.ErrForbidden + } + + contribution, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + + contribution.Status = input.Status + // Note: The feedback handling is not fully implemented. + // In a real application, this might create a new comment associated with the contribution. + + if err := c.repo.Update(ctx, contribution); err != nil { + return nil, err + } + return contribution, nil } \ No newline at end of file diff --git a/internal/app/copyright/main_test.go b/internal/app/copyright/main_test.go index 20e98dc..e7dfab6 100644 --- a/internal/app/copyright/main_test.go +++ b/internal/app/copyright/main_test.go @@ -4,7 +4,6 @@ import ( "context" "gorm.io/gorm" "tercul/internal/domain" - "tercul/internal/domain/work" ) type mockCopyrightRepository struct { @@ -173,11 +172,11 @@ func (m *mockCopyrightRepository) WithTx(ctx context.Context, fn func(tx *gorm.D } type mockWorkRepository struct { - work.WorkRepository - getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) + domain.WorkRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) } -func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { if m.getByIDWithOptionsFunc != nil { return m.getByIDWithOptionsFunc(ctx, id, options) } diff --git a/internal/app/copyright/queries.go b/internal/app/copyright/queries.go index 8340ac1..a7769db 100644 --- a/internal/app/copyright/queries.go +++ b/internal/app/copyright/queries.go @@ -4,14 +4,13 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/log" ) // CopyrightQueries contains the query handlers for copyright. type CopyrightQueries struct { repo domain.CopyrightRepository - workRepo work.WorkRepository + workRepo domain.WorkRepository authorRepo domain.AuthorRepository bookRepo domain.BookRepository publisherRepo domain.PublisherRepository @@ -19,7 +18,7 @@ type CopyrightQueries struct { } // NewCopyrightQueries creates a new CopyrightQueries handler. -func NewCopyrightQueries(repo domain.CopyrightRepository, workRepo work.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *CopyrightQueries { +func NewCopyrightQueries(repo domain.CopyrightRepository, workRepo domain.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *CopyrightQueries { return &CopyrightQueries{repo: repo, workRepo: workRepo, authorRepo: authorRepo, bookRepo: bookRepo, publisherRepo: publisherRepo, sourceRepo: sourceRepo} } diff --git a/internal/app/copyright/queries_test.go b/internal/app/copyright/queries_test.go index ad1cd7e..bf52d31 100644 --- a/internal/app/copyright/queries_test.go +++ b/internal/app/copyright/queries_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "tercul/internal/domain" - "tercul/internal/domain/work" "testing" ) @@ -100,8 +99,8 @@ func (s *CopyrightQueriesSuite) TestGetCopyrightsForSource_RepoError() { func (s *CopyrightQueriesSuite) TestGetCopyrightsForWork_Success() { copyrights := []*domain.Copyright{{Name: "Test Copyright"}} - s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { - return &work.Work{Copyrights: copyrights}, nil + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return &domain.Work{Copyrights: copyrights}, nil } c, err := s.queries.GetCopyrightsForWork(context.Background(), 1) assert.NoError(s.T(), err) @@ -109,7 +108,7 @@ func (s *CopyrightQueriesSuite) TestGetCopyrightsForWork_Success() { } func (s *CopyrightQueriesSuite) TestGetCopyrightsForWork_RepoError() { - s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return nil, errors.New("db error") } c, err := s.queries.GetCopyrightsForWork(context.Background(), 1) diff --git a/internal/app/localization/commands.go b/internal/app/localization/commands.go index c23b14c..6734d49 100644 --- a/internal/app/localization/commands.go +++ b/internal/app/localization/commands.go @@ -1,13 +1,13 @@ package localization -import "tercul/internal/domain/localization" +import "tercul/internal/domain" // LocalizationCommands contains the command handlers for the localization aggregate. type LocalizationCommands struct { - repo localization.LocalizationRepository + repo domain.LocalizationRepository } // NewLocalizationCommands creates a new LocalizationCommands handler. -func NewLocalizationCommands(repo localization.LocalizationRepository) *LocalizationCommands { +func NewLocalizationCommands(repo domain.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 index 536a5db..c3f2261 100644 --- a/internal/app/localization/queries.go +++ b/internal/app/localization/queries.go @@ -2,16 +2,16 @@ package localization import ( "context" - "tercul/internal/domain/localization" + "tercul/internal/domain" ) // LocalizationQueries contains the query handlers for the localization aggregate. type LocalizationQueries struct { - repo localization.LocalizationRepository + repo domain.LocalizationRepository } // NewLocalizationQueries creates a new LocalizationQueries handler. -func NewLocalizationQueries(repo localization.LocalizationRepository) *LocalizationQueries { +func NewLocalizationQueries(repo domain.LocalizationRepository) *LocalizationQueries { return &LocalizationQueries{repo: repo} } diff --git a/internal/app/localization/service.go b/internal/app/localization/service.go index 00e68d5..f3d6558 100644 --- a/internal/app/localization/service.go +++ b/internal/app/localization/service.go @@ -1,6 +1,6 @@ package localization -import "tercul/internal/domain/localization" +import "tercul/internal/domain" // Service is the application service for the localization aggregate. type Service struct { @@ -9,7 +9,7 @@ type Service struct { } // NewService creates a new localization Service. -func NewService(repo localization.LocalizationRepository) *Service { +func NewService(repo domain.LocalizationRepository) *Service { return &Service{ Commands: NewLocalizationCommands(repo), Queries: NewLocalizationQueries(repo), diff --git a/internal/app/monetization/main_test.go b/internal/app/monetization/main_test.go index a38dab8..6cd6451 100644 --- a/internal/app/monetization/main_test.go +++ b/internal/app/monetization/main_test.go @@ -3,7 +3,6 @@ package monetization import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" ) type mockMonetizationRepository struct { @@ -98,11 +97,11 @@ func (m *mockMonetizationRepository) RemoveMonetizationFromSource(ctx context.Co } type mockWorkRepository struct { - work.WorkRepository - getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) + domain.WorkRepository + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) } -func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { if m.getByIDWithOptionsFunc != nil { return m.getByIDWithOptionsFunc(ctx, id, options) } diff --git a/internal/app/monetization/queries.go b/internal/app/monetization/queries.go index 00410e6..488b682 100644 --- a/internal/app/monetization/queries.go +++ b/internal/app/monetization/queries.go @@ -4,14 +4,13 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/log" ) // MonetizationQueries contains the query handlers for monetization. type MonetizationQueries struct { repo domain.MonetizationRepository - workRepo work.WorkRepository + workRepo domain.WorkRepository authorRepo domain.AuthorRepository bookRepo domain.BookRepository publisherRepo domain.PublisherRepository @@ -19,7 +18,7 @@ type MonetizationQueries struct { } // NewMonetizationQueries creates a new MonetizationQueries handler. -func NewMonetizationQueries(repo domain.MonetizationRepository, workRepo work.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *MonetizationQueries { +func NewMonetizationQueries(repo domain.MonetizationRepository, workRepo domain.WorkRepository, authorRepo domain.AuthorRepository, bookRepo domain.BookRepository, publisherRepo domain.PublisherRepository, sourceRepo domain.SourceRepository) *MonetizationQueries { return &MonetizationQueries{repo: repo, workRepo: workRepo, authorRepo: authorRepo, bookRepo: bookRepo, publisherRepo: publisherRepo, sourceRepo: sourceRepo} } diff --git a/internal/app/monetization/queries_test.go b/internal/app/monetization/queries_test.go index fae8ead..7ba483e 100644 --- a/internal/app/monetization/queries_test.go +++ b/internal/app/monetization/queries_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "tercul/internal/domain" - "tercul/internal/domain/work" "testing" ) @@ -82,8 +81,8 @@ func (s *MonetizationQueriesSuite) TestListMonetizations_Success() { func (s *MonetizationQueriesSuite) TestGetMonetizationsForWork_Success() { monetizations := []*domain.Monetization{{Amount: 10.0}} - s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { - return &work.Work{Monetizations: monetizations}, nil + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return &domain.Work{Monetizations: monetizations}, nil } m, err := s.queries.GetMonetizationsForWork(context.Background(), 1) assert.NoError(s.T(), err) @@ -91,7 +90,7 @@ func (s *MonetizationQueriesSuite) TestGetMonetizationsForWork_Success() { } func (s *MonetizationQueriesSuite) TestGetMonetizationsForWork_RepoError() { - s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { + s.workRepo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return nil, errors.New("db error") } m, err := s.queries.GetMonetizationsForWork(context.Background(), 1) diff --git a/internal/app/search/service.go b/internal/app/search/service.go index 3b1a715..1bf3f8a 100644 --- a/internal/app/search/service.go +++ b/internal/app/search/service.go @@ -2,38 +2,44 @@ package search import ( "context" - "fmt" "tercul/internal/app/localization" - "tercul/internal/domain/work" + "tercul/internal/domain" + domainsearch "tercul/internal/domain/search" "tercul/internal/platform/log" - "tercul/internal/platform/search" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" ) -// IndexService pushes localized snapshots into Weaviate for search -type IndexService interface { - IndexWork(ctx context.Context, work work.Work) error +// Service is the application service for searching. +type Service interface { + Search(ctx context.Context, query string, page, pageSize int, filters domain.SearchFilters) (*domain.SearchResults, error) + IndexWork(ctx context.Context, work domain.Work) error } -type indexService struct { +type service struct { + searchClient domainsearch.SearchClient localization *localization.Service - weaviate search.WeaviateWrapper - tracer trace.Tracer } -func NewIndexService(localization *localization.Service, weaviate search.WeaviateWrapper) IndexService { - return &indexService{ +// NewService creates a new search Service. +func NewService(searchClient domainsearch.SearchClient, localization *localization.Service) Service { + return &service{ + searchClient: searchClient, localization: localization, - weaviate: weaviate, - tracer: otel.Tracer("search.service"), } } -func (s *indexService) IndexWork(ctx context.Context, work work.Work) error { - ctx, span := s.tracer.Start(ctx, "IndexWork") - defer span.End() +// Search performs a search across all searchable entities. +func (s *service) Search(ctx context.Context, query string, page, pageSize int, filters domain.SearchFilters) (*domain.SearchResults, error) { + // For now, this is a mock implementation that returns empty results. + // TODO: Implement the actual search logic. + return &domain.SearchResults{ + Works: []domain.Work{}, + Translations: []domain.Translation{}, + Authors: []domain.Author{}, + Total: 0, + }, nil +} + +func (s *service) IndexWork(ctx context.Context, work domain.Work) error { logger := log.FromContext(ctx).With("work_id", work.ID) logger.Debug("Indexing work") @@ -46,13 +52,11 @@ func (s *indexService) IndexWork(ctx context.Context, work work.Work) error { content = "" } - err = s.weaviate.IndexWork(ctx, &work, content) + err = s.searchClient.IndexWork(ctx, &work, content) if err != nil { logger.Error(err, "Failed to index work in Weaviate") return err } logger.Info("Successfully indexed work") return nil -} - -func formatID(id uint) string { return fmt.Sprintf("%d", id) } +} \ No newline at end of file diff --git a/internal/app/search/service_test.go b/internal/app/search/service_test.go index a8a4c01..a1db8f3 100644 --- a/internal/app/search/service_test.go +++ b/internal/app/search/service_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/mock" "tercul/internal/app/localization" "tercul/internal/domain" - "tercul/internal/domain/work" ) type mockLocalizationRepository struct { @@ -42,7 +41,7 @@ type mockWeaviateWrapper struct { mock.Mock } -func (m *mockWeaviateWrapper) IndexWork(ctx context.Context, work *work.Work, content string) error { +func (m *mockWeaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { args := m.Called(ctx, work, content) return args.Error(0) } @@ -51,10 +50,10 @@ func TestIndexService_IndexWork(t *testing.T) { localizationRepo := new(mockLocalizationRepository) localizationService := localization.NewService(localizationRepo) weaviateWrapper := new(mockWeaviateWrapper) - service := NewIndexService(localizationService, weaviateWrapper) + service := NewService(weaviateWrapper, localizationService) ctx := context.Background() - testWork := work.Work{ + testWork := domain.Work{ TranslatableModel: domain.TranslatableModel{ BaseModel: domain.BaseModel{ID: 1}, Language: "en", diff --git a/internal/app/translation/commands_test.go b/internal/app/translation/commands_test.go index ae51cce..ce85e88 100644 --- a/internal/app/translation/commands_test.go +++ b/internal/app/translation/commands_test.go @@ -7,7 +7,6 @@ import ( "tercul/internal/app/authz" "tercul/internal/app/translation" "tercul/internal/domain" - "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" "tercul/internal/testutil" @@ -48,7 +47,7 @@ func (s *TranslationCommandsTestSuite) SetupTest() { } func (s *TranslationCommandsTestSuite) TestCreateOrUpdateTranslation() { - testWork := &work.Work{ + testWork := &domain.Work{ TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, } input := translation.CreateOrUpdateTranslationInput{ diff --git a/internal/app/translation/queries.go b/internal/app/translation/queries.go index dd0c240..4e2243c 100644 --- a/internal/app/translation/queries.go +++ b/internal/app/translation/queries.go @@ -63,3 +63,10 @@ func (q *TranslationQueries) Translations(ctx context.Context) ([]domain.Transla defer span.End() return q.repo.ListAll(ctx) } + +// ListTranslations returns a paginated list of translations for a work, with optional language filtering. +func (q *TranslationQueries) ListTranslations(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + ctx, span := q.tracer.Start(ctx, "ListTranslations") + defer span.End() + return q.repo.ListByWorkIDPaginated(ctx, workID, language, page, pageSize) +} diff --git a/internal/app/user/queries.go b/internal/app/user/queries.go index f161c58..f02ef25 100644 --- a/internal/app/user/queries.go +++ b/internal/app/user/queries.go @@ -7,12 +7,13 @@ import ( // UserQueries contains the query handlers for the user aggregate. type UserQueries struct { - repo domain.UserRepository + repo domain.UserRepository + profileRepo domain.UserProfileRepository } // NewUserQueries creates a new UserQueries handler. -func NewUserQueries(repo domain.UserRepository) *UserQueries { - return &UserQueries{repo: repo} +func NewUserQueries(repo domain.UserRepository, profileRepo domain.UserProfileRepository) *UserQueries { + return &UserQueries{repo: repo, profileRepo: profileRepo} } // User returns a user by ID. @@ -39,3 +40,8 @@ func (q *UserQueries) UsersByRole(ctx context.Context, role domain.UserRole) ([] func (q *UserQueries) Users(ctx context.Context) ([]domain.User, error) { return q.repo.ListAll(ctx) } + +// UserProfile returns a user profile by user ID. +func (q *UserQueries) UserProfile(ctx context.Context, userID uint) (*domain.UserProfile, error) { + return q.profileRepo.GetByUserID(ctx, userID) +} diff --git a/internal/app/user/service.go b/internal/app/user/service.go index c8a277c..8a49568 100644 --- a/internal/app/user/service.go +++ b/internal/app/user/service.go @@ -12,9 +12,9 @@ type Service struct { } // NewService creates a new user Service. -func NewService(repo domain.UserRepository, authzSvc *authz.Service) *Service { +func NewService(repo domain.UserRepository, authzSvc *authz.Service, profileRepo domain.UserProfileRepository) *Service { return &Service{ Commands: NewUserCommands(repo, authzSvc), - Queries: NewUserQueries(repo), + Queries: NewUserQueries(repo, profileRepo), } } diff --git a/internal/app/user/work_repo_mock_test.go b/internal/app/user/work_repo_mock_test.go index 4ef0856..e511b0e 100644 --- a/internal/app/user/work_repo_mock_test.go +++ b/internal/app/user/work_repo_mock_test.go @@ -3,44 +3,43 @@ package user import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "gorm.io/gorm" ) type mockWorkRepoForUserTests struct{} -func (m *mockWorkRepoForUserTests) Create(ctx context.Context, entity *work.Work) error { return nil } -func (m *mockWorkRepoForUserTests) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *mockWorkRepoForUserTests) Create(ctx context.Context, entity *domain.Work) error { return nil } +func (m *mockWorkRepoForUserTests) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return nil } -func (m *mockWorkRepoForUserTests) GetByID(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) GetByID(ctx context.Context, id uint) (*domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) Update(ctx context.Context, entity *work.Work) error { return nil } -func (m *mockWorkRepoForUserTests) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *mockWorkRepoForUserTests) Update(ctx context.Context, entity *domain.Work) error { return nil } +func (m *mockWorkRepoForUserTests) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return nil } func (m *mockWorkRepoForUserTests) Delete(ctx context.Context, id uint) error { return nil } func (m *mockWorkRepoForUserTests) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } -func (m *mockWorkRepoForUserTests) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *mockWorkRepoForUserTests) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } -func (m *mockWorkRepoForUserTests) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { +func (m *mockWorkRepoForUserTests) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil } +func (m *mockWorkRepoForUserTests) ListAll(ctx context.Context) ([]domain.Work, error) { return nil, nil } func (m *mockWorkRepoForUserTests) Count(ctx context.Context) (int64, error) { return 0, nil } func (m *mockWorkRepoForUserTests) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { return 0, nil } -func (m *mockWorkRepoForUserTests) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { +func (m *mockWorkRepoForUserTests) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { return nil, nil } func (m *mockWorkRepoForUserTests) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } @@ -48,32 +47,36 @@ func (m *mockWorkRepoForUserTests) BeginTx(ctx context.Context) (*gorm.DB, error func (m *mockWorkRepoForUserTests) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } -func (m *mockWorkRepoForUserTests) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { +func (m *mockWorkRepoForUserTests) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { +func (m *mockWorkRepoForUserTests) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { +func (m *mockWorkRepoForUserTests) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *mockWorkRepoForUserTests) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } -func (m *mockWorkRepoForUserTests) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } func (m *mockWorkRepoForUserTests) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { return false, nil } -func (m *mockWorkRepoForUserTests) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) { return nil, nil } -func (m *mockWorkRepoForUserTests) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { +func (m *mockWorkRepoForUserTests) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) { + return nil, nil +} + +func (m *mockWorkRepoForUserTests) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { return nil, nil } \ No newline at end of file diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index 3bfe620..63683a4 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -7,7 +7,6 @@ import ( "tercul/internal/app/authz" "tercul/internal/domain" "tercul/internal/domain/search" - "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" "go.opentelemetry.io/otel" @@ -17,14 +16,14 @@ import ( // WorkCommands contains the command handlers for the work aggregate. type WorkCommands struct { - repo work.WorkRepository + repo domain.WorkRepository searchClient search.SearchClient authzSvc *authz.Service tracer trace.Tracer } // NewWorkCommands creates a new WorkCommands handler. -func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands { +func NewWorkCommands(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands { return &WorkCommands{ repo: repo, searchClient: searchClient, @@ -34,7 +33,7 @@ func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, } // CreateWork creates a new work. -func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.Work, error) { +func (c *WorkCommands) CreateWork(ctx context.Context, work *domain.Work) (*domain.Work, error) { ctx, span := c.tracer.Start(ctx, "CreateWork") defer span.End() if work == nil { @@ -59,7 +58,7 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W } // UpdateWork updates an existing work after performing an authorization check. -func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error { +func (c *WorkCommands) UpdateWork(ctx context.Context, work *domain.Work) error { ctx, span := c.tracer.Start(ctx, "UpdateWork") defer span.End() if work == nil { @@ -161,7 +160,7 @@ func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) e return domain.ErrUnauthorized } - // The repo is a work.WorkRepository, which embeds domain.BaseRepository. + // The repo is a domain.WorkRepository, which embeds domain.BaseRepository. // We can use the WithTx method from the base repository to run the merge in a transaction. err := c.repo.WithTx(ctx, func(tx *gorm.DB) error { // We need to use the transaction `tx` for all operations inside this function. @@ -234,7 +233,7 @@ func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) e if err = tx.Select("Authors", "Tags", "Categories", "Copyrights", "Monetizations").Delete(sourceWork).Error; err != nil { return fmt.Errorf("failed to delete source work associations: %w", err) } - if err = tx.Delete(&work.Work{}, sourceID).Error; err != nil { + if err = tx.Delete(&domain.Work{}, sourceID).Error; err != nil { return fmt.Errorf("failed to delete source work: %w", err) } @@ -259,7 +258,7 @@ func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) e } func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error { - var sourceStats work.WorkStats + var sourceStats domain.WorkStats err := tx.Where("work_id = ?", sourceWorkID).First(&sourceStats).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("failed to get source work stats: %w", err) @@ -270,7 +269,7 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error { return nil } - var targetStats work.WorkStats + var targetStats domain.WorkStats err = tx.Where("work_id = ?", targetWorkID).First(&targetStats).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -291,7 +290,7 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error { } // Delete the old source stats - if err = tx.Delete(&work.WorkStats{}, sourceStats.ID).Error; err != nil { + if err = tx.Delete(&domain.WorkStats{}, sourceStats.ID).Error; err != nil { return fmt.Errorf("failed to delete source work stats: %w", err) } diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index 785cb00..0f3c289 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -13,7 +13,6 @@ import ( "tercul/internal/app/authz" "tercul/internal/data/sql" "tercul/internal/domain" - workdomain "tercul/internal/domain/work" platform_auth "tercul/internal/platform/auth" ) @@ -37,7 +36,7 @@ func TestWorkCommandsSuite(t *testing.T) { } func (s *WorkCommandsSuite) TestCreateWork_Success() { - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} _, err := s.commands.CreateWork(context.Background(), work) assert.NoError(s.T(), err) } @@ -48,20 +47,20 @@ func (s *WorkCommandsSuite) TestCreateWork_Nil() { } func (s *WorkCommandsSuite) TestCreateWork_EmptyTitle() { - work := &workdomain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} _, err := s.commands.CreateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestCreateWork_EmptyLanguage() { - work := &workdomain.Work{Title: "Test Work"} + work := &domain.Work{Title: "Test Work"} _, err := s.commands.CreateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestCreateWork_RepoError() { - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} - s.repo.createFunc = func(ctx context.Context, w *workdomain.Work) error { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + s.repo.createFunc = func(ctx context.Context, w *domain.Work) error { return errors.New("db error") } _, err := s.commands.CreateWork(context.Background(), work) @@ -70,10 +69,10 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() { func (s *WorkCommandsSuite) TestUpdateWork_Success() { ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 - s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) { return work, nil } s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { @@ -90,29 +89,29 @@ func (s *WorkCommandsSuite) TestUpdateWork_Nil() { } func (s *WorkCommandsSuite) TestUpdateWork_ZeroID() { - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} err := s.commands.UpdateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestUpdateWork_EmptyTitle() { - work := &workdomain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 err := s.commands.UpdateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestUpdateWork_EmptyLanguage() { - work := &workdomain.Work{Title: "Test Work"} + work := &domain.Work{Title: "Test Work"} work.ID = 1 err := s.commands.UpdateWork(context.Background(), work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 - s.repo.updateFunc = func(ctx context.Context, w *workdomain.Work) error { + s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error { return errors.New("db error") } err := s.commands.UpdateWork(context.Background(), work) @@ -121,10 +120,10 @@ func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { func (s *WorkCommandsSuite) TestDeleteWork_Success() { ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) - work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 - s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) { return work, nil } s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { @@ -160,15 +159,15 @@ func TestMergeWork_Integration(t *testing.T) { // Run migrations for all relevant tables err = db.AutoMigrate( - &workdomain.Work{}, + &domain.Work{}, &domain.Translation{}, &domain.Author{}, &domain.Tag{}, &domain.Category{}, &domain.Copyright{}, &domain.Monetization{}, - &workdomain.WorkStats{}, - &workdomain.WorkAuthor{}, + &domain.WorkStats{}, + &domain.WorkAuthor{}, ) assert.NoError(t, err) @@ -191,7 +190,7 @@ func TestMergeWork_Integration(t *testing.T) { tag2 := &domain.Tag{Name: "Tag Two"} db.Create(tag2) - sourceWork := &workdomain.Work{ + sourceWork := &domain.Work{ TranslatableModel: domain.TranslatableModel{Language: "en"}, Title: "Source Work", Authors: []*domain.Author{author1}, @@ -200,9 +199,9 @@ func TestMergeWork_Integration(t *testing.T) { db.Create(sourceWork) db.Create(&domain.Translation{Title: "Source English", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"}) db.Create(&domain.Translation{Title: "Source French", Language: "fr", TranslatableID: sourceWork.ID, TranslatableType: "works"}) - db.Create(&workdomain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5}) + db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5}) - targetWork := &workdomain.Work{ + targetWork := &domain.Work{ TranslatableModel: domain.TranslatableModel{Language: "en"}, Title: "Target Work", Authors: []*domain.Author{author2}, @@ -210,7 +209,7 @@ func TestMergeWork_Integration(t *testing.T) { } db.Create(targetWork) db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"}) - db.Create(&workdomain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10}) + db.Create(&domain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10}) // --- Execute Merge --- ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) @@ -219,13 +218,13 @@ func TestMergeWork_Integration(t *testing.T) { // --- Assertions --- // 1. Source work should be deleted - var deletedWork workdomain.Work + var deletedWork domain.Work err = db.First(&deletedWork, sourceWork.ID).Error assert.Error(t, err) assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) // 2. Target work should have merged data - var finalTargetWork workdomain.Work + var finalTargetWork domain.Work db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID) assert.Len(t, finalTargetWork.Translations, 2, "Should have two translations after merge") @@ -248,13 +247,13 @@ func TestMergeWork_Integration(t *testing.T) { assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged") // 3. Stats should be merged - var finalStats workdomain.WorkStats + var finalStats domain.WorkStats db.Where("work_id = ?", targetWork.ID).First(&finalStats) assert.Equal(t, int64(30), finalStats.Views, "Views should be summed") assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed") // 4. Source stats should be deleted - var deletedStats workdomain.WorkStats + var deletedStats domain.WorkStats err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error assert.Error(t, err, "Source stats should be deleted") assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go index 0581967..02b034e 100644 --- a/internal/app/work/main_test.go +++ b/internal/app/work/main_test.go @@ -3,21 +3,20 @@ package work import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" ) type mockWorkRepository struct { - work.WorkRepository - createFunc func(ctx context.Context, work *work.Work) error - updateFunc func(ctx context.Context, work *work.Work) error + domain.WorkRepository + createFunc func(ctx context.Context, work *domain.Work) error + updateFunc func(ctx context.Context, work *domain.Work) error deleteFunc func(ctx context.Context, id uint) error - getByIDFunc func(ctx context.Context, id uint) (*work.Work, error) - listFunc func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) - getWithTranslationsFunc func(ctx context.Context, id uint) (*work.Work, error) - findByTitleFunc func(ctx context.Context, title string) ([]work.Work, error) - findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error) - findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error) - findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) + getByIDFunc func(ctx context.Context, id uint) (*domain.Work, error) + listFunc func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) + getWithTranslationsFunc func(ctx context.Context, id uint) (*domain.Work, error) + findByTitleFunc func(ctx context.Context, title string) ([]domain.Work, error) + findByAuthorFunc func(ctx context.Context, authorID uint) ([]domain.Work, error) + findByCategoryFunc func(ctx context.Context, categoryID uint) ([]domain.Work, error) + findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error) } @@ -28,13 +27,13 @@ func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID return false, nil } -func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error { +func (m *mockWorkRepository) Create(ctx context.Context, work *domain.Work) error { if m.createFunc != nil { return m.createFunc(ctx, work) } return nil } -func (m *mockWorkRepository) Update(ctx context.Context, work *work.Work) error { +func (m *mockWorkRepository) Update(ctx context.Context, work *domain.Work) error { if m.updateFunc != nil { return m.updateFunc(ctx, work) } @@ -46,43 +45,43 @@ func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error { } return nil } -func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { if m.getByIDFunc != nil { return m.getByIDFunc(ctx, id) } - return &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil + return &domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil } -func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { if m.listFunc != nil { return m.listFunc(ctx, page, pageSize) } return nil, nil } -func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { if m.getWithTranslationsFunc != nil { return m.getWithTranslationsFunc(ctx, id) } return nil, nil } -func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { +func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { if m.findByTitleFunc != nil { return m.findByTitleFunc(ctx, title) } return nil, nil } -func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { +func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { if m.findByAuthorFunc != nil { return m.findByAuthorFunc(ctx, authorID) } return nil, nil } -func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { +func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { if m.findByCategoryFunc != nil { return m.findByCategoryFunc(ctx, categoryID) } return nil, nil } -func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { if m.findByLanguageFunc != nil { return m.findByLanguageFunc(ctx, language, page, pageSize) } @@ -90,10 +89,10 @@ func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string } type mockSearchClient struct { - indexWorkFunc func(ctx context.Context, work *work.Work, pipeline string) error + indexWorkFunc func(ctx context.Context, work *domain.Work, pipeline string) error } -func (m *mockSearchClient) IndexWork(ctx context.Context, work *work.Work, pipeline string) error { +func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error { if m.indexWorkFunc != nil { return m.indexWorkFunc(ctx, work, pipeline) } diff --git a/internal/app/work/queries.go b/internal/app/work/queries.go index c6df566..9f08ab8 100644 --- a/internal/app/work/queries.go +++ b/internal/app/work/queries.go @@ -4,42 +4,19 @@ import ( "context" "errors" "tercul/internal/domain" - "tercul/internal/domain/work" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" ) -// WorkAnalytics contains analytics data for a work -type WorkAnalytics struct { - WorkID uint - ViewCount int64 - LikeCount int64 - CommentCount int64 - BookmarkCount int64 - TranslationCount int64 - ReadabilityScore float64 - SentimentScore float64 - TopKeywords []string - PopularTranslations []TranslationAnalytics -} - -// TranslationAnalytics contains analytics data for a translation -type TranslationAnalytics struct { - TranslationID uint - Language string - ViewCount int64 - LikeCount int64 -} - // WorkQueries contains the query handlers for the work aggregate. type WorkQueries struct { - repo work.WorkRepository + repo domain.WorkRepository tracer trace.Tracer } // NewWorkQueries creates a new WorkQueries handler. -func NewWorkQueries(repo work.WorkRepository) *WorkQueries { +func NewWorkQueries(repo domain.WorkRepository) *WorkQueries { return &WorkQueries{ repo: repo, tracer: otel.Tracer("work.queries"), @@ -47,7 +24,7 @@ func NewWorkQueries(repo work.WorkRepository) *WorkQueries { } // GetWorkByID retrieves a work by ID. -func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*work.Work, error) { +func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*domain.Work, error) { ctx, span := q.tracer.Start(ctx, "GetWorkByID") defer span.End() if id == 0 { @@ -57,14 +34,14 @@ func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*work.Work, err } // ListWorks returns a paginated list of works. -func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { ctx, span := q.tracer.Start(ctx, "ListWorks") defer span.End() return q.repo.List(ctx, page, pageSize) } // GetWorkWithTranslations retrieves a work with its translations. -func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { ctx, span := q.tracer.Start(ctx, "GetWorkWithTranslations") defer span.End() if id == 0 { @@ -74,7 +51,7 @@ func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*wo } // FindWorksByTitle finds works by title. -func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]work.Work, error) { +func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]domain.Work, error) { ctx, span := q.tracer.Start(ctx, "FindWorksByTitle") defer span.End() if title == "" { @@ -84,7 +61,7 @@ func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]wor } // FindWorksByAuthor finds works by author ID. -func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { +func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { ctx, span := q.tracer.Start(ctx, "FindWorksByAuthor") defer span.End() if authorID == 0 { @@ -94,7 +71,7 @@ func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]w } // FindWorksByCategory finds works by category ID. -func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { +func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { ctx, span := q.tracer.Start(ctx, "FindWorksByCategory") defer span.End() if categoryID == 0 { @@ -104,7 +81,7 @@ func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) } // FindWorksByLanguage finds works by language. -func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { ctx, span := q.tracer.Start(ctx, "FindWorksByLanguage") defer span.End() if language == "" { @@ -112,3 +89,13 @@ func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, } return q.repo.FindByLanguage(ctx, language, page, pageSize) } + +// ListByCollectionID finds works by collection ID. +func (q *WorkQueries) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { + ctx, span := q.tracer.Start(ctx, "ListByCollectionID") + defer span.End() + if collectionID == 0 { + return nil, errors.New("invalid collection ID") + } + return q.repo.ListByCollectionID(ctx, collectionID) +} diff --git a/internal/app/work/queries_test.go b/internal/app/work/queries_test.go index f1acde4..3f14630 100644 --- a/internal/app/work/queries_test.go +++ b/internal/app/work/queries_test.go @@ -5,7 +5,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "tercul/internal/domain" - workdomain "tercul/internal/domain/work" "testing" ) @@ -25,9 +24,9 @@ func TestWorkQueriesSuite(t *testing.T) { } func (s *WorkQueriesSuite) TestGetWorkByID_Success() { - work := &workdomain.Work{Title: "Test Work"} + work := &domain.Work{Title: "Test Work"} work.ID = 1 - s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) { return work, nil } w, err := s.queries.GetWorkByID(context.Background(), 1) @@ -42,8 +41,8 @@ func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { } func (s *WorkQueriesSuite) TestListWorks_Success() { - works := &domain.PaginatedResult[workdomain.Work]{} - s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[workdomain.Work], error) { + works := &domain.PaginatedResult[domain.Work]{} + s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return works, nil } w, err := s.queries.ListWorks(context.Background(), 1, 10) @@ -52,9 +51,9 @@ func (s *WorkQueriesSuite) TestListWorks_Success() { } func (s *WorkQueriesSuite) TestGetWorkWithTranslations_Success() { - work := &workdomain.Work{Title: "Test Work"} + work := &domain.Work{Title: "Test Work"} work.ID = 1 - s.repo.getWithTranslationsFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) { + s.repo.getWithTranslationsFunc = func(ctx context.Context, id uint) (*domain.Work, error) { return work, nil } w, err := s.queries.GetWorkWithTranslations(context.Background(), 1) @@ -69,8 +68,8 @@ func (s *WorkQueriesSuite) TestGetWorkWithTranslations_ZeroID() { } func (s *WorkQueriesSuite) TestFindWorksByTitle_Success() { - works := []workdomain.Work{{Title: "Test Work"}} - s.repo.findByTitleFunc = func(ctx context.Context, title string) ([]workdomain.Work, error) { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByTitleFunc = func(ctx context.Context, title string) ([]domain.Work, error) { return works, nil } w, err := s.queries.FindWorksByTitle(context.Background(), "Test") @@ -85,8 +84,8 @@ func (s *WorkQueriesSuite) TestFindWorksByTitle_Empty() { } func (s *WorkQueriesSuite) TestFindWorksByAuthor_Success() { - works := []workdomain.Work{{Title: "Test Work"}} - s.repo.findByAuthorFunc = func(ctx context.Context, authorID uint) ([]workdomain.Work, error) { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByAuthorFunc = func(ctx context.Context, authorID uint) ([]domain.Work, error) { return works, nil } w, err := s.queries.FindWorksByAuthor(context.Background(), 1) @@ -101,8 +100,8 @@ func (s *WorkQueriesSuite) TestFindWorksByAuthor_ZeroID() { } func (s *WorkQueriesSuite) TestFindWorksByCategory_Success() { - works := []workdomain.Work{{Title: "Test Work"}} - s.repo.findByCategoryFunc = func(ctx context.Context, categoryID uint) ([]workdomain.Work, error) { + works := []domain.Work{{Title: "Test Work"}} + s.repo.findByCategoryFunc = func(ctx context.Context, categoryID uint) ([]domain.Work, error) { return works, nil } w, err := s.queries.FindWorksByCategory(context.Background(), 1) @@ -117,8 +116,8 @@ func (s *WorkQueriesSuite) TestFindWorksByCategory_ZeroID() { } func (s *WorkQueriesSuite) TestFindWorksByLanguage_Success() { - works := &domain.PaginatedResult[workdomain.Work]{} - s.repo.findByLanguageFunc = func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[workdomain.Work], error) { + works := &domain.PaginatedResult[domain.Work]{} + s.repo.findByLanguageFunc = func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return works, nil } w, err := s.queries.FindWorksByLanguage(context.Background(), "en", 1, 10) diff --git a/internal/app/work/service.go b/internal/app/work/service.go index 9a1317b..cec8c1a 100644 --- a/internal/app/work/service.go +++ b/internal/app/work/service.go @@ -2,8 +2,8 @@ package work import ( "tercul/internal/app/authz" + "tercul/internal/domain" "tercul/internal/domain/search" - "tercul/internal/domain/work" ) // Service is the application service for the work aggregate. @@ -13,7 +13,7 @@ type Service struct { } // NewService creates a new work Service. -func NewService(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service { +func NewService(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service { return &Service{ Commands: NewWorkCommands(repo, searchClient, authzSvc), Queries: NewWorkQueries(repo), diff --git a/internal/data/sql/analytics_repository.go b/internal/data/sql/analytics_repository.go index 4351342..f405bda 100644 --- a/internal/data/sql/analytics_repository.go +++ b/internal/data/sql/analytics_repository.go @@ -5,7 +5,6 @@ import ( "fmt" "tercul/internal/app/analytics" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/config" "time" @@ -52,7 +51,7 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u // Using a transaction to ensure atomicity return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // First, try to update the existing record - result := tx.Model(&work.WorkStats{}).Where("work_id = ?", workID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) + result := tx.Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) if result.Error != nil { return result.Error } @@ -60,14 +59,14 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u // If no rows were affected, the record does not exist, so create it if result.RowsAffected == 0 { initialData := map[string]interface{}{"work_id": workID, field: value} - return tx.Model(&work.WorkStats{}).Create(initialData).Error + return tx.Model(&domain.WorkStats{}).Create(initialData).Error } return nil }) } -func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { +func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { ctx, span := r.tracer.Start(ctx, "GetTrendingWorks") defer span.End() var trendingWorks []*domain.Trending @@ -81,7 +80,7 @@ func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod s } if len(trendingWorks) == 0 { - return []*work.Work{}, nil + return []*domain.Work{}, nil } workIDs := make([]uint, len(trendingWorks)) @@ -89,19 +88,19 @@ func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod s workIDs[i] = tw.EntityID } - var works []*work.Work + var works []*domain.Work err = r.db.WithContext(ctx). Where("id IN ?", workIDs). Find(&works).Error // This part is tricky because the order from the IN clause is not guaranteed. // We need to re-order the works based on the trending rank. - workMap := make(map[uint]*work.Work) + workMap := make(map[uint]*domain.Work) for _, w := range works { workMap[w.ID] = w } - orderedWorks := make([]*work.Work, len(workIDs)) + orderedWorks := make([]*domain.Work, len(workIDs)) for i, id := range workIDs { if w, ok := workMap[id]; ok { orderedWorks[i] = w @@ -133,10 +132,10 @@ func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, t }) } -func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { +func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { ctx, span := r.tracer.Start(ctx, "UpdateWorkStats") defer span.End() - return r.db.WithContext(ctx).Model(&work.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error + return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error } func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { @@ -145,11 +144,11 @@ func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, transl return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error } -func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { +func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { ctx, span := r.tracer.Start(ctx, "GetOrCreateWorkStats") defer span.End() - var stats work.WorkStats - err := r.db.WithContext(ctx).Where(work.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error + var stats domain.WorkStats + err := r.db.WithContext(ctx).Where(domain.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error return &stats, err } diff --git a/internal/data/sql/auth_repository.go b/internal/data/sql/auth_repository.go index 7b5714e..535697e 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/auth" + "tercul/internal/domain" "tercul/internal/platform/config" "time" @@ -16,7 +16,7 @@ type authRepository struct { tracer trace.Tracer } -func NewAuthRepository(db *gorm.DB, cfg *config.Config) auth.AuthRepository { +func NewAuthRepository(db *gorm.DB, cfg *config.Config) domain.AuthRepository { return &authRepository{ db: db, tracer: otel.Tracer("auth.repository"), @@ -26,7 +26,7 @@ func NewAuthRepository(db *gorm.DB, cfg *config.Config) auth.AuthRepository { func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error { ctx, span := r.tracer.Start(ctx, "StoreToken") defer span.End() - session := &auth.UserSession{ + session := &domain.UserSession{ UserID: userID, Token: token, ExpiresAt: expiresAt, @@ -37,5 +37,5 @@ func (r *authRepository) StoreToken(ctx context.Context, userID uint, token stri func (r *authRepository) DeleteToken(ctx context.Context, token string) error { ctx, span := r.tracer.Start(ctx, "DeleteToken") defer span.End() - return r.db.WithContext(ctx).Where("token = ?", token).Delete(&auth.UserSession{}).Error + return r.db.WithContext(ctx).Where("token = ?", token).Delete(&domain.UserSession{}).Error } diff --git a/internal/data/sql/base_repository.go b/internal/data/sql/base_repository.go index 6ddfadc..e406bd2 100644 --- a/internal/data/sql/base_repository.go +++ b/internal/data/sql/base_repository.go @@ -32,7 +32,7 @@ type BaseRepositoryImpl[T any] struct { } // NewBaseRepositoryImpl creates a new BaseRepositoryImpl -func NewBaseRepositoryImpl[T any](db *gorm.DB, cfg *config.Config) domain.BaseRepository[T] { +func NewBaseRepositoryImpl[T any](db *gorm.DB, cfg *config.Config) *BaseRepositoryImpl[T] { return &BaseRepositoryImpl[T]{ db: db, tracer: otel.Tracer("base.repository"), diff --git a/internal/data/sql/localization_repository.go b/internal/data/sql/localization_repository.go index 53b04aa..4af8602 100644 --- a/internal/data/sql/localization_repository.go +++ b/internal/data/sql/localization_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/localization" "tercul/internal/platform/config" "go.opentelemetry.io/otel" @@ -16,7 +15,7 @@ type localizationRepository struct { tracer trace.Tracer } -func NewLocalizationRepository(db *gorm.DB, cfg *config.Config) localization.LocalizationRepository { +func NewLocalizationRepository(db *gorm.DB, cfg *config.Config) domain.LocalizationRepository { return &localizationRepository{ db: db, tracer: otel.Tracer("localization.repository"), @@ -26,7 +25,7 @@ func NewLocalizationRepository(db *gorm.DB, cfg *config.Config) localization.Loc func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { ctx, span := r.tracer.Start(ctx, "GetTranslation") defer span.End() - var l localization.Localization + var l domain.Localization err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&l).Error if err != nil { return "", err @@ -37,7 +36,7 @@ func (r *localizationRepository) GetTranslation(ctx context.Context, key string, func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { ctx, span := r.tracer.Start(ctx, "GetTranslations") defer span.End() - var localizations []localization.Localization + var localizations []domain.Localization err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error if err != nil { return nil, err diff --git a/internal/data/sql/monetization_repository.go b/internal/data/sql/monetization_repository.go index 70caff8..45684fe 100644 --- a/internal/data/sql/monetization_repository.go +++ b/internal/data/sql/monetization_repository.go @@ -3,7 +3,6 @@ package sql import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/config" "go.opentelemetry.io/otel" @@ -29,7 +28,7 @@ func NewMonetizationRepository(db *gorm.DB, cfg *config.Config) domain.Monetizat func (r *monetizationRepository) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error { ctx, span := r.tracer.Start(ctx, "AddMonetizationToWork") defer span.End() - workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} + workRecord := &domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Append(monetization) } @@ -37,7 +36,7 @@ func (r *monetizationRepository) AddMonetizationToWork(ctx context.Context, work func (r *monetizationRepository) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error { ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromWork") defer span.End() - workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} + workRecord := &domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Delete(monetization) } diff --git a/internal/data/sql/monetization_repository_test.go b/internal/data/sql/monetization_repository_test.go index c50f21c..946a8da 100644 --- a/internal/data/sql/monetization_repository_test.go +++ b/internal/data/sql/monetization_repository_test.go @@ -5,7 +5,6 @@ import ( "testing" "tercul/internal/data/sql" "tercul/internal/domain" - workdomain "tercul/internal/domain/work" "tercul/internal/platform/config" "tercul/internal/testutil" @@ -44,7 +43,7 @@ func (s *MonetizationRepositoryTestSuite) TestAddMonetizationToWork() { s.Require().NoError(err) // Verify that the association was created in the database - var foundWork workdomain.Work + var foundWork domain.Work err = s.DB.Preload("Monetizations").First(&foundWork, work.ID).Error s.Require().NoError(err) s.Require().Len(foundWork.Monetizations, 1) diff --git a/internal/data/sql/repositories.go b/internal/data/sql/repositories.go index 66c8bde..38e5752 100644 --- a/internal/data/sql/repositories.go +++ b/internal/data/sql/repositories.go @@ -3,17 +3,15 @@ package sql import ( "tercul/internal/app/analytics" "tercul/internal/domain" - "tercul/internal/domain/auth" - "tercul/internal/domain/localization" - "tercul/internal/domain/work" "tercul/internal/platform/config" "gorm.io/gorm" ) type Repositories struct { - Work work.WorkRepository + Work domain.WorkRepository User domain.UserRepository + UserProfile domain.UserProfileRepository Author domain.AuthorRepository Translation domain.TranslationRepository Comment domain.CommentRepository @@ -29,8 +27,8 @@ type Repositories struct { Monetization domain.MonetizationRepository Contribution domain.ContributionRepository Analytics analytics.Repository - Auth auth.AuthRepository - Localization localization.LocalizationRepository + Auth domain.AuthRepository + Localization domain.LocalizationRepository } // NewRepositories creates a new Repositories container @@ -38,6 +36,7 @@ func NewRepositories(db *gorm.DB, cfg *config.Config) *Repositories { return &Repositories{ Work: NewWorkRepository(db, cfg), User: NewUserRepository(db, cfg), + UserProfile: NewUserProfileRepository(db, cfg), Author: NewAuthorRepository(db, cfg), Translation: NewTranslationRepository(db, cfg), Comment: NewCommentRepository(db, cfg), diff --git a/internal/data/sql/translation_repository.go b/internal/data/sql/translation_repository.go index 14ef37a..d81fc06 100644 --- a/internal/data/sql/translation_repository.go +++ b/internal/data/sql/translation_repository.go @@ -37,6 +37,63 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ( return translations, nil } +// ListByWorkIDPaginated finds translations by work ID with pagination and optional language filtering. +func (r *translationRepository) ListByWorkIDPaginated(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + ctx, span := r.tracer.Start(ctx, "ListByWorkIDPaginated") + defer span.End() + + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 // Default page size + } + + var translations []domain.Translation + var totalCount int64 + + query := r.db.WithContext(ctx).Model(&domain.Translation{}).Where("translatable_id = ? AND translatable_type = ?", workID, "works") + + if language != nil { + query = query.Where("language = ?", *language) + } + + // Get total count + if err := query.Count(&totalCount).Error; err != nil { + return nil, err + } + + // Calculate offset + offset := (page - 1) * pageSize + + // Get paginated data + if err := query.Offset(offset).Limit(pageSize).Find(&translations).Error; err != nil { + return nil, err + } + + // Calculate total pages + totalPages := 0 + if pageSize > 0 { + totalPages = int(totalCount) / pageSize + if int(totalCount)%pageSize > 0 { + totalPages++ + } + } + + hasNext := page < totalPages + hasPrev := page > 1 + + return &domain.PaginatedResult[domain.Translation]{ + Items: translations, + TotalCount: totalCount, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + HasNext: hasNext, + HasPrev: hasPrev, + }, nil +} + // Upsert creates a new translation or updates an existing one based on the unique // composite key of (translatable_id, translatable_type, language). func (r *translationRepository) Upsert(ctx context.Context, translation *domain.Translation) error { diff --git a/internal/data/sql/work_repository.go b/internal/data/sql/work_repository.go index d0dbc91..2eaa1b3 100644 --- a/internal/data/sql/work_repository.go +++ b/internal/data/sql/work_repository.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/config" "go.opentelemetry.io/otel" @@ -14,25 +13,25 @@ import ( ) type workRepository struct { - domain.BaseRepository[work.Work] + *BaseRepositoryImpl[domain.Work] db *gorm.DB tracer trace.Tracer } // NewWorkRepository creates a new WorkRepository. -func NewWorkRepository(db *gorm.DB, cfg *config.Config) work.WorkRepository { +func NewWorkRepository(db *gorm.DB, cfg *config.Config) domain.WorkRepository { return &workRepository{ - BaseRepository: NewBaseRepositoryImpl[work.Work](db, cfg), - db: db, - tracer: otel.Tracer("work.repository"), + BaseRepositoryImpl: NewBaseRepositoryImpl[domain.Work](db, cfg), + db: db, + tracer: otel.Tracer("work.repository"), } } // FindByTitle finds works by title (partial match) -func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { +func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { ctx, span := r.tracer.Start(ctx, "FindByTitle") defer span.End() - var works []work.Work + var works []domain.Work if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil { return nil, err } @@ -40,10 +39,10 @@ func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]work. } // FindByAuthor finds works by author ID -func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { +func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { ctx, span := r.tracer.Start(ctx, "FindByAuthor") defer span.End() - var works []work.Work + var works []domain.Work if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id"). Where("work_authors.author_id = ?", authorID). Find(&works).Error; err != nil { @@ -53,10 +52,10 @@ func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]wor } // FindByCategory finds works by category ID -func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { +func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { ctx, span := r.tracer.Start(ctx, "FindByCategory") defer span.End() - var works []work.Work + var works []domain.Work if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id"). Where("work_categories.category_id = ?", categoryID). Find(&works).Error; err != nil { @@ -66,7 +65,7 @@ func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([ } // FindByLanguage finds works by language with pagination -func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { ctx, span := r.tracer.Start(ctx, "FindByLanguage") defer span.End() if page < 1 { @@ -77,11 +76,11 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa pageSize = 20 } - var works []work.Work + var works []domain.Work var totalCount int64 // Get total count - if err := r.db.WithContext(ctx).Model(&work.Work{}).Where("language = ?", language).Count(&totalCount).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&domain.Work{}).Where("language = ?", language).Count(&totalCount).Error; err != nil { return nil, err } @@ -104,7 +103,7 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa hasNext := page < totalPages hasPrev := page > 1 - return &domain.PaginatedResult[work.Work]{ + return &domain.PaginatedResult[domain.Work]{ Items: works, TotalCount: totalCount, Page: page, @@ -115,17 +114,30 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa }, nil } +// ListByCollectionID finds works by collection ID +func (r *workRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { + ctx, span := r.tracer.Start(ctx, "ListByCollectionID") + defer span.End() + var works []domain.Work + if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.work_id = works.id"). + Where("collection_works.collection_id = ?", collectionID). + Find(&works).Error; err != nil { + return nil, err + } + return works, nil +} + // Delete removes a work and its associations func (r *workRepository) Delete(ctx context.Context, id uint) error { ctx, span := r.tracer.Start(ctx, "Delete") defer span.End() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Manually delete associations - if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil { + if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&domain.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil { return err } // Also delete the work itself - if err := tx.Delete(&work.Work{}, id).Error; err != nil { + if err := tx.Delete(&domain.Work{}, id).Error; err != nil { return err } return nil @@ -133,14 +145,14 @@ func (r *workRepository) Delete(ctx context.Context, id uint) error { } // GetWithTranslations gets a work with its translations -func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { ctx, span := r.tracer.Start(ctx, "GetWithTranslations") defer span.End() return r.FindWithPreload(ctx, []string{"Translations"}, id) } // GetWithAssociations gets a work with all of its direct and many-to-many associations. -func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { +func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) { ctx, span := r.tracer.Start(ctx, "GetWithAssociations") defer span.End() associations := []string{ @@ -155,10 +167,10 @@ func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*wor } // GetWithAssociationsInTx gets a work with all associations within a transaction. -func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { +func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) { ctx, span := r.tracer.Start(ctx, "GetWithAssociationsInTx") defer span.End() - var entity work.Work + var entity domain.Work query := tx.WithContext(ctx) associations := []string{ "Translations", @@ -198,7 +210,7 @@ func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uin } // ListWithTranslations lists works with their translations -func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { ctx, span := r.tracer.Start(ctx, "ListWithTranslations") defer span.End() if page < 1 { @@ -209,11 +221,11 @@ func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSiz pageSize = 20 } - var works []work.Work + var works []domain.Work var totalCount int64 // Get total count - if err := r.db.WithContext(ctx).Model(&work.Work{}).Count(&totalCount).Error; err != nil { + if err := r.db.WithContext(ctx).Model(&domain.Work{}).Count(&totalCount).Error; err != nil { return nil, err } @@ -236,7 +248,7 @@ func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSiz hasNext := page < totalPages hasPrev := page > 1 - return &domain.PaginatedResult[work.Work]{ + return &domain.PaginatedResult[domain.Work]{ Items: works, TotalCount: totalCount, Page: page, diff --git a/internal/data/sql/work_repository_test.go b/internal/data/sql/work_repository_test.go index 1928c6f..a075458 100644 --- a/internal/data/sql/work_repository_test.go +++ b/internal/data/sql/work_repository_test.go @@ -5,7 +5,6 @@ import ( "testing" "tercul/internal/data/sql" "tercul/internal/domain" - "tercul/internal/domain/work" "tercul/internal/platform/config" "tercul/internal/testutil" @@ -14,7 +13,7 @@ import ( type WorkRepositoryTestSuite struct { testutil.IntegrationTestSuite - WorkRepo work.WorkRepository + WorkRepo domain.WorkRepository } func (s *WorkRepositoryTestSuite) SetupSuite() { @@ -33,7 +32,7 @@ func (s *WorkRepositoryTestSuite) TestCreateWork() { } s.Require().NoError(s.DB.Create(copyright).Error) - workModel := &work.Work{ + workModel := &domain.Work{ Title: "New Test Work", TranslatableModel: domain.TranslatableModel{ Language: "en", @@ -49,7 +48,7 @@ func (s *WorkRepositoryTestSuite) TestCreateWork() { s.NotZero(workModel.ID) // Verify that the work was actually created in the database - var foundWork work.Work + var foundWork domain.Work err = s.DB.Preload("Copyrights").First(&foundWork, workModel.ID).Error s.Require().NoError(err) s.Equal("New Test Work", foundWork.Title) @@ -112,7 +111,7 @@ func (s *WorkRepositoryTestSuite) TestUpdateWork() { s.Require().NoError(err) // Verify that the work was actually updated in the database - var foundWork work.Work + var foundWork domain.Work err = s.DB.Preload("Copyrights").First(&foundWork, workModel.ID).Error s.Require().NoError(err) s.Equal("Updated Title", foundWork.Title) @@ -136,7 +135,7 @@ func (s *WorkRepositoryTestSuite) TestDeleteWork() { s.Require().NoError(err) // Verify that the work was actually deleted from the database - var foundWork work.Work + var foundWork domain.Work err = s.DB.First(&foundWork, workModel.ID).Error s.Require().Error(err) diff --git a/internal/domain/auth/entity.go b/internal/domain/auth/entity.go deleted file mode 100644 index e1c91bb..0000000 --- a/internal/domain/auth/entity.go +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 578db57..0000000 --- a/internal/domain/auth/repo.go +++ /dev/null @@ -1,12 +0,0 @@ -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 e1ffb1c..f59d1e9 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -929,6 +929,151 @@ type Embedding struct { Translation *Translation `gorm:"foreignKey:TranslationID"` } +// SearchFilters defines the available filters for a search query. +type SearchFilters struct { + Languages []string + Categories []string + Tags []string + Authors []string + DateFrom *time.Time + DateTo *time.Time +} + +// SearchResults represents the results of a search query. +type SearchResults struct { + Works []Work + Translations []Translation + Authors []Author + Total int64 +} + +// Work-related enums and structs, moved from domain/work/entity.go to break import cycle. + +type WorkStatus string + +const ( + WorkStatusDraft WorkStatus = "draft" + WorkStatusPublished WorkStatus = "published" + WorkStatusArchived WorkStatus = "archived" + WorkStatusDeleted WorkStatus = "deleted" +) + +type WorkType string + +const ( + WorkTypePoetry WorkType = "poetry" + WorkTypeProse WorkType = "prose" + WorkTypeDrama WorkType = "drama" + WorkTypeEssay WorkType = "essay" + WorkTypeNovel WorkType = "novel" + WorkTypeShortStory WorkType = "short_story" + WorkTypeNovella WorkType = "novella" + WorkTypePlay WorkType = "play" + WorkTypeScript WorkType = "script" + WorkTypeOther WorkType = "other" +) + +type Work struct { + TranslatableModel + Title string `gorm:"size:255;not null"` + Description string `gorm:"type:text"` + Type WorkType `gorm:"size:50;default:'other'"` + Status WorkStatus `gorm:"size:50;default:'draft'"` + PublishedAt *time.Time + Translations []*Translation `gorm:"polymorphic:Translatable"` + Authors []*Author `gorm:"many2many:work_authors"` + Tags []*Tag `gorm:"many2many:work_tags"` + Categories []*Category `gorm:"many2many:work_categories"` + Copyrights []*Copyright `gorm:"many2many:work_copyrights;constraint:OnDelete:CASCADE"` + Monetizations []*Monetization `gorm:"many2many:work_monetizations;constraint:OnDelete:CASCADE"` +} + +func (w *Work) BeforeSave(tx *gorm.DB) error { + if w.Title == "" { + w.Title = "Untitled Work" + } + return nil +} + +func (w *Work) GetID() uint { return w.ID } +func (w *Work) GetType() string { return "Work" } +func (w *Work) GetDefaultLanguage() string { return w.Language } + +type WorkStats struct { + BaseModel + Views int64 `gorm:"default:0"` + Likes int64 `gorm:"default:0"` + Comments int64 `gorm:"default:0"` + Bookmarks int64 `gorm:"default:0"` + Shares int64 `gorm:"default:0"` + TranslationCount int64 `gorm:"default:0"` + ReadingTime int `gorm:"default:0"` + Complexity float64 `gorm:"type:decimal(5,2);default:0.0"` + Sentiment float64 `gorm:"type:decimal(5,2);default:0.0"` + WorkID uint `gorm:"uniqueIndex;index"` + Work *Work `gorm:"foreignKey:WorkID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` +} + +// Add combines the values of another WorkStats into this one. +func (ws *WorkStats) Add(other *WorkStats) { + if other == nil { + return + } + ws.Views += other.Views + ws.Likes += other.Likes + ws.Comments += other.Comments + ws.Bookmarks += other.Bookmarks + ws.Shares += other.Shares + ws.TranslationCount += other.TranslationCount + ws.ReadingTime += other.ReadingTime + // Note: Complexity and Sentiment are not additive. We could average them, + // but for now, we'll just keep the target's values. +} + +type WorkSeries struct { + BaseModel + WorkID uint `gorm:"index;uniqueIndex:uniq_work_series"` + Work *Work `gorm:"foreignKey:WorkID"` + SeriesID uint `gorm:"index;uniqueIndex:uniq_work_series"` + Series *Series `gorm:"foreignKey:SeriesID"` + NumberInSeries int `gorm:"default:0"` +} + +type BookWork struct { + BaseModel + BookID uint `gorm:"index;uniqueIndex:uniq_book_work"` + Book *Book `gorm:"foreignKey:BookID"` + WorkID uint `gorm:"index;uniqueIndex:uniq_book_work"` + Work *Work `gorm:"foreignKey:WorkID"` + Order int `gorm:"default:0"` +} + +type WorkAuthor struct { + BaseModel + WorkID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` + Work *Work `gorm:"foreignKey:WorkID"` + AuthorID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` + Author *Author `gorm:"foreignKey:AuthorID"` + Role string `gorm:"size:50;default:'author';uniqueIndex:uniq_work_author_role"` + Ordinal int `gorm:"default:0"` +} + +type WorkCopyright struct { + WorkID uint `gorm:"primaryKey;index"` + CopyrightID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (WorkCopyright) TableName() string { return "work_copyrights" } + +type WorkMonetization struct { + WorkID uint `gorm:"primaryKey;index"` + MonetizationID uint `gorm:"primaryKey;index"` + CreatedAt time.Time +} + +func (WorkMonetization) TableName() string { return "work_monetizations" } + type Localization struct { BaseModel Key string `gorm:"size:255;not null;uniqueIndex:uniq_localization_key_language"` diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 7c37bab..f806797 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -14,4 +14,5 @@ var ( ErrForbidden = errors.New("forbidden") ErrValidation = errors.New("validation failed") ErrConflict = errors.New("conflict with existing resource") + ErrUserNotFound = errors.New("user not found") ) \ No newline at end of file diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index 85cc633..7fbabf5 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -3,6 +3,7 @@ package domain import ( "context" "gorm.io/gorm" + "time" ) // PaginatedResult represents a paginated result set @@ -176,6 +177,7 @@ type TagRepository interface { type TranslationRepository interface { BaseRepository[Translation] ListByWorkID(ctx context.Context, workID uint) ([]Translation, error) + ListByWorkIDPaginated(ctx context.Context, workID uint, language *string, page, pageSize int) (*PaginatedResult[Translation], error) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error) ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error) ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error) @@ -263,3 +265,32 @@ type CopyrightRepository interface { GetTranslations(ctx context.Context, copyrightID uint) ([]CopyrightTranslation, error) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*CopyrightTranslation, error) } + +// WorkRepository defines methods specific to Work. +type WorkRepository interface { + BaseRepository[Work] + FindByTitle(ctx context.Context, title string) ([]Work, error) + FindByAuthor(ctx context.Context, authorID uint) ([]Work, error) + FindByCategory(ctx context.Context, categoryID uint) ([]Work, error) + FindByLanguage(ctx context.Context, language string, page, pageSize int) (*PaginatedResult[Work], error) + GetWithTranslations(ctx context.Context, id uint) (*Work, error) + GetWithAssociations(ctx context.Context, id uint) (*Work, error) + GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*Work, error) + ListWithTranslations(ctx context.Context, page, pageSize int) (*PaginatedResult[Work], error) + IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) + ListByCollectionID(ctx context.Context, collectionID uint) ([]Work, error) +} + +// 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 +} + +// 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) + GetWorkContent(ctx context.Context, workID uint, language string) (string, error) +} diff --git a/internal/domain/localization/entity.go b/internal/domain/localization/entity.go deleted file mode 100644 index 8c2c69b..0000000 --- a/internal/domain/localization/entity.go +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index a80f94b..0000000 --- a/internal/domain/localization/repo.go +++ /dev/null @@ -1,13 +0,0 @@ -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) - GetWorkContent(ctx context.Context, workID 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 index b05da9a..5d8c2ab 100644 --- a/internal/domain/search/client.go +++ b/internal/domain/search/client.go @@ -2,10 +2,10 @@ package search import ( "context" - "tercul/internal/domain/work" + "tercul/internal/domain" ) // SearchClient defines the interface for a search client. type SearchClient interface { - IndexWork(ctx context.Context, work *work.Work, pipeline string) error + IndexWork(ctx context.Context, work *domain.Work, content string) error } \ No newline at end of file diff --git a/internal/domain/work/.keep b/internal/domain/work/.keep deleted file mode 100644 index d431563..0000000 --- a/internal/domain/work/.keep +++ /dev/null @@ -1 +0,0 @@ -# This file is created to ensure the directory structure is in place. diff --git a/internal/domain/work/entity.go b/internal/domain/work/entity.go deleted file mode 100644 index 6bef315..0000000 --- a/internal/domain/work/entity.go +++ /dev/null @@ -1,132 +0,0 @@ -package work - -import ( - "gorm.io/gorm" - "tercul/internal/domain" - "time" -) - -type WorkStatus string - -const ( - WorkStatusDraft WorkStatus = "draft" - WorkStatusPublished WorkStatus = "published" - WorkStatusArchived WorkStatus = "archived" - WorkStatusDeleted WorkStatus = "deleted" -) - -type WorkType string - -const ( - WorkTypePoetry WorkType = "poetry" - WorkTypeProse WorkType = "prose" - WorkTypeDrama WorkType = "drama" - WorkTypeEssay WorkType = "essay" - WorkTypeNovel WorkType = "novel" - WorkTypeShortStory WorkType = "short_story" - WorkTypeNovella WorkType = "novella" - WorkTypePlay WorkType = "play" - WorkTypeScript WorkType = "script" - WorkTypeOther WorkType = "other" -) - -type Work struct { - domain.TranslatableModel - Title string `gorm:"size:255;not null"` - Description string `gorm:"type:text"` - Type WorkType `gorm:"size:50;default:'other'"` - Status WorkStatus `gorm:"size:50;default:'draft'"` - PublishedAt *time.Time - Translations []*domain.Translation `gorm:"polymorphic:Translatable"` - Authors []*domain.Author `gorm:"many2many:work_authors"` - Tags []*domain.Tag `gorm:"many2many:work_tags"` - Categories []*domain.Category `gorm:"many2many:work_categories"` - Copyrights []*domain.Copyright `gorm:"many2many:work_copyrights;constraint:OnDelete:CASCADE"` - Monetizations []*domain.Monetization `gorm:"many2many:work_monetizations;constraint:OnDelete:CASCADE"` -} - -func (w *Work) BeforeSave(tx *gorm.DB) error { - if w.Title == "" { - w.Title = "Untitled Work" - } - return nil -} - -func (w *Work) GetID() uint { return w.ID } -func (w *Work) GetType() string { return "Work" } -func (w *Work) GetDefaultLanguage() string { return w.Language } - -type WorkStats struct { - domain.BaseModel - Views int64 `gorm:"default:0"` - Likes int64 `gorm:"default:0"` - Comments int64 `gorm:"default:0"` - Bookmarks int64 `gorm:"default:0"` - Shares int64 `gorm:"default:0"` - TranslationCount int64 `gorm:"default:0"` - ReadingTime int `gorm:"default:0"` - Complexity float64 `gorm:"type:decimal(5,2);default:0.0"` - Sentiment float64 `gorm:"type:decimal(5,2);default:0.0"` - WorkID uint `gorm:"uniqueIndex;index"` - Work *Work `gorm:"foreignKey:WorkID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` -} - -// Add combines the values of another WorkStats into this one. -func (ws *WorkStats) Add(other *WorkStats) { - if other == nil { - return - } - ws.Views += other.Views - ws.Likes += other.Likes - ws.Comments += other.Comments - ws.Bookmarks += other.Bookmarks - ws.Shares += other.Shares - ws.TranslationCount += other.TranslationCount - ws.ReadingTime += other.ReadingTime - // Note: Complexity and Sentiment are not additive. We could average them, - // but for now, we'll just keep the target's values. -} - -type WorkSeries struct { - domain.BaseModel - WorkID uint `gorm:"index;uniqueIndex:uniq_work_series"` - Work *Work `gorm:"foreignKey:WorkID"` - SeriesID uint `gorm:"index;uniqueIndex:uniq_work_series"` - Series *domain.Series `gorm:"foreignKey:SeriesID"` - NumberInSeries int `gorm:"default:0"` -} - -type BookWork struct { - domain.BaseModel - BookID uint `gorm:"index;uniqueIndex:uniq_book_work"` - Book *domain.Book `gorm:"foreignKey:BookID"` - WorkID uint `gorm:"index;uniqueIndex:uniq_book_work"` - Work *Work `gorm:"foreignKey:WorkID"` - Order int `gorm:"default:0"` -} - -type WorkAuthor struct { - domain.BaseModel - WorkID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` - Work *Work `gorm:"foreignKey:WorkID"` - AuthorID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` - Author *domain.Author `gorm:"foreignKey:AuthorID"` - Role string `gorm:"size:50;default:'author';uniqueIndex:uniq_work_author_role"` - Ordinal int `gorm:"default:0"` -} - -type WorkCopyright struct { - WorkID uint `gorm:"primaryKey;index"` - CopyrightID uint `gorm:"primaryKey;index"` - CreatedAt time.Time -} - -func (WorkCopyright) TableName() string { return "work_copyrights" } - -type WorkMonetization struct { - WorkID uint `gorm:"primaryKey;index"` - MonetizationID uint `gorm:"primaryKey;index"` - CreatedAt time.Time -} - -func (WorkMonetization) TableName() string { return "work_monetizations" } \ No newline at end of file diff --git a/internal/domain/work/repo.go b/internal/domain/work/repo.go deleted file mode 100644 index d9208cb..0000000 --- a/internal/domain/work/repo.go +++ /dev/null @@ -1,21 +0,0 @@ -package work - -import ( - "context" - "gorm.io/gorm" - "tercul/internal/domain" -) - -// WorkRepository defines methods specific to Work. -type WorkRepository interface { - domain.BaseRepository[Work] - FindByTitle(ctx context.Context, title string) ([]Work, error) - FindByAuthor(ctx context.Context, authorID uint) ([]Work, error) - FindByCategory(ctx context.Context, categoryID uint) ([]Work, error) - FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error) - GetWithTranslations(ctx context.Context, id uint) (*Work, error) - GetWithAssociations(ctx context.Context, id uint) (*Work, error) - GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*Work, error) - ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error) - IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) -} \ No newline at end of file diff --git a/internal/enrichment/service.go b/internal/enrichment/service.go index dbd810c..cc1487b 100644 --- a/internal/enrichment/service.go +++ b/internal/enrichment/service.go @@ -3,7 +3,6 @@ package enrichment import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" ) // Service is the main entrypoint for the enrichment functionality. @@ -31,7 +30,7 @@ type AuthorEnricher interface { // WorkEnricher defines the interface for enriching work data. type WorkEnricher interface { - Enrich(ctx context.Context, work *work.Work) error + Enrich(ctx context.Context, work *domain.Work) error Name() string } @@ -57,7 +56,7 @@ func (s *Service) EnrichAuthor(ctx context.Context, author *domain.Author) error } // EnrichWork iterates through registered work enrichers and applies them. -func (s *Service) EnrichWork(ctx context.Context, work *work.Work) error { +func (s *Service) EnrichWork(ctx context.Context, work *domain.Work) error { for _, enricher := range s.WorkEnrichers { if err := enricher.Enrich(ctx, work); err != nil { return err diff --git a/internal/jobs/linguistics/analysis_repository.go b/internal/jobs/linguistics/analysis_repository.go index ac7f99d..7045a42 100644 --- a/internal/jobs/linguistics/analysis_repository.go +++ b/internal/jobs/linguistics/analysis_repository.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "tercul/internal/domain" - "tercul/internal/domain/work" "gorm.io/gorm" "tercul/internal/platform/log" @@ -23,7 +22,7 @@ type AnalysisRepository interface { readabilityScore *domain.ReadabilityScore, languageAnalysis *domain.LanguageAnalysis) error // GetWorkByID fetches a work by ID - GetWorkByID(ctx context.Context, workID uint) (*work.Work, error) + GetWorkByID(ctx context.Context, workID uint) (*domain.Work, error) // GetAnalysisData fetches persisted analysis data for a work GetAnalysisData(ctx context.Context, workID uint) (*domain.TextMetadata, *domain.ReadabilityScore, *domain.LanguageAnalysis, error) @@ -47,7 +46,7 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI } // Determine language from the work record to avoid hardcoded defaults - var workRecord work.Work + var workRecord domain.Work if err := r.db.WithContext(ctx).First(&workRecord, workID).Error; err != nil { logger.Error(err, "Failed to fetch work for language") return fmt.Errorf("failed to fetch work for language: %w", err) @@ -90,7 +89,7 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) { logger := log.FromContext(ctx).With("workID", workID) // First, get the work to determine its language - var workRecord work.Work + var workRecord domain.Work if err := r.db.First(&workRecord, workID).Error; err != nil { logger.Error(err, "Failed to fetch work for content retrieval") return "", fmt.Errorf("failed to fetch work: %w", err) @@ -125,8 +124,8 @@ func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint } // GetWorkByID fetches a work by ID -func (r *GORMAnalysisRepository) GetWorkByID(ctx context.Context, workID uint) (*work.Work, error) { - var workRecord work.Work +func (r *GORMAnalysisRepository) GetWorkByID(ctx context.Context, workID uint) (*domain.Work, error) { + var workRecord domain.Work if err := r.db.WithContext(ctx).First(&workRecord, workID).Error; err != nil { return nil, fmt.Errorf("failed to fetch work: %w", err) } diff --git a/internal/jobs/linguistics/sync_job.go b/internal/jobs/linguistics/sync_job.go index 652e6cf..8b1c042 100644 --- a/internal/jobs/linguistics/sync_job.go +++ b/internal/jobs/linguistics/sync_job.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "tercul/internal/domain" - "tercul/internal/domain/work" "time" "github.com/hibiken/asynq" @@ -61,7 +60,7 @@ func (j *LinguisticSyncJob) EnqueueAnalysisForAllWorks() error { log.Println("Enqueueing linguistic analysis jobs for all works...") var workIDs []uint - if err := j.DB.Model(&work.Work{}).Pluck("id", &workIDs).Error; err != nil { + if err := j.DB.Model(&domain.Work{}).Pluck("id", &workIDs).Error; err != nil { return fmt.Errorf("error fetching work IDs: %w", err) } diff --git a/internal/jobs/sync/batch_processor.go b/internal/jobs/sync/batch_processor.go index f811d28..4b2e68b 100644 --- a/internal/jobs/sync/batch_processor.go +++ b/internal/jobs/sync/batch_processor.go @@ -7,17 +7,19 @@ import ( "log" "strings" "tercul/internal/platform/config" - "tercul/internal/platform/search" + + "github.com/weaviate/weaviate-go-client/v5/weaviate" ) // BatchProcessor handles batch processing of entities for sync operations type BatchProcessor struct { db *gorm.DB defaultBatchSize int + weaviateClient *weaviate.Client } // NewBatchProcessor creates a new BatchProcessor -func NewBatchProcessor(db *gorm.DB, cfg *config.Config) *BatchProcessor { +func NewBatchProcessor(db *gorm.DB, cfg *config.Config, weaviateClient *weaviate.Client) *BatchProcessor { batchSize := cfg.BatchSize if batchSize <= 0 { batchSize = DefaultBatchSize @@ -26,6 +28,7 @@ func NewBatchProcessor(db *gorm.DB, cfg *config.Config) *BatchProcessor { return &BatchProcessor{ db: db, defaultBatchSize: batchSize, + weaviateClient: weaviateClient, } } @@ -140,9 +143,9 @@ func (bp *BatchProcessor) CreateObjectsBatch(ctx context.Context, className stri return nil } -// createObject creates a single object in Weaviate using the existing client +// createObject creates a single object in Weaviate using the injected client. func (bp *BatchProcessor) createObject(ctx context.Context, className, objID string, properties map[string]interface{}) error { - _, err := search.Client.Data().Creator(). + _, err := bp.weaviateClient.Data().Creator(). WithClassName(className). WithID(objID). WithProperties(properties). diff --git a/internal/jobs/sync/edges_sync.go b/internal/jobs/sync/edges_sync.go index 0519475..7394bf7 100644 --- a/internal/jobs/sync/edges_sync.go +++ b/internal/jobs/sync/edges_sync.go @@ -54,6 +54,6 @@ func (s *SyncJob) SyncEdgesBatch(ctx context.Context, batchSize, offset int) err edgeMaps = append(edgeMaps, edgeMap) } - batchProcessor := NewBatchProcessor(s.DB, s.Cfg) + batchProcessor := NewBatchProcessor(s.DB, s.Cfg, s.WeaviateClient) return batchProcessor.CreateObjectsBatch(ctx, "Edge", edgeMaps) } diff --git a/internal/jobs/sync/entities_sync.go b/internal/jobs/sync/entities_sync.go index 1ce4245..29100ee 100644 --- a/internal/jobs/sync/entities_sync.go +++ b/internal/jobs/sync/entities_sync.go @@ -76,6 +76,6 @@ func (s *SyncJob) SyncAllEntities(ctx context.Context) error { // syncEntities is a generic function to sync a given entity type. func (s *SyncJob) syncEntities(className string, ctx context.Context) error { - batchProcessor := NewBatchProcessor(s.DB, s.Cfg) + batchProcessor := NewBatchProcessor(s.DB, s.Cfg, s.WeaviateClient) return batchProcessor.ProcessAllEntities(ctx, className) } diff --git a/internal/jobs/sync/queue.go b/internal/jobs/sync/queue.go index d010709..7caac03 100644 --- a/internal/jobs/sync/queue.go +++ b/internal/jobs/sync/queue.go @@ -57,13 +57,9 @@ func EnqueueEdgeSync(client *asynq.Client, batchSize, offset int) error { return nil } -// RegisterQueueHandlers registers all sync job handlers with the Asynq server -func RegisterQueueHandlers(srv *asynq.Server, syncJob *SyncJob) { - mux := asynq.NewServeMux() +// RegisterQueueHandlers registers all sync job handlers with the Asynq server mux. +func RegisterQueueHandlers(mux *asynq.ServeMux, syncJob *SyncJob) { mux.HandleFunc(TaskFullSync, syncJob.HandleFullSync) mux.HandleFunc(TaskEntitySync, syncJob.HandleEntitySync) mux.HandleFunc(TaskEdgeSync, syncJob.HandleEdgeSync) - if err := srv.Run(mux); err != nil { - log.Printf("Failed to start asynq server: %v", err) - } } diff --git a/internal/jobs/sync/syncjob.go b/internal/jobs/sync/syncjob.go index 66acef8..e09dace 100644 --- a/internal/jobs/sync/syncjob.go +++ b/internal/jobs/sync/syncjob.go @@ -6,22 +6,25 @@ import ( "tercul/internal/platform/config" "github.com/hibiken/asynq" + "github.com/weaviate/weaviate-go-client/v5/weaviate" "gorm.io/gorm" ) // SyncJob manages the sync process. type SyncJob struct { - DB *gorm.DB - AsynqClient *asynq.Client - Cfg *config.Config + DB *gorm.DB + AsynqClient *asynq.Client + Cfg *config.Config + WeaviateClient *weaviate.Client } // NewSyncJob initializes a new SyncJob. -func NewSyncJob(db *gorm.DB, aClient *asynq.Client, cfg *config.Config) *SyncJob { +func NewSyncJob(db *gorm.DB, aClient *asynq.Client, cfg *config.Config, weaviateClient *weaviate.Client) *SyncJob { return &SyncJob{ - DB: db, - AsynqClient: aClient, - Cfg: cfg, + DB: db, + AsynqClient: aClient, + Cfg: cfg, + WeaviateClient: weaviateClient, } } diff --git a/internal/platform/search/weaviate_client.go b/internal/platform/search/weaviate_client.go index 07f8b6a..44c1fc2 100644 --- a/internal/platform/search/weaviate_client.go +++ b/internal/platform/search/weaviate_client.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "log" - "tercul/internal/domain/work" + "tercul/internal/domain" "time" "github.com/weaviate/weaviate-go-client/v5/weaviate" @@ -13,7 +13,7 @@ import ( var Client *weaviate.Client // UpsertWork inserts or updates a Work object in Weaviate -func UpsertWork(client *weaviate.Client, work work.Work) error { +func UpsertWork(client *weaviate.Client, work domain.Work) error { // Create a properties map with the fields that exist in the Work model properties := map[string]interface{}{ "language": work.Language, diff --git a/internal/platform/search/weaviate_wrapper.go b/internal/platform/search/weaviate_wrapper.go index a5e834d..20563ce 100644 --- a/internal/platform/search/weaviate_wrapper.go +++ b/internal/platform/search/weaviate_wrapper.go @@ -3,14 +3,14 @@ package search import ( "context" "fmt" - "tercul/internal/domain/work" + "tercul/internal/domain" "time" "github.com/weaviate/weaviate-go-client/v5/weaviate" ) type WeaviateWrapper interface { - IndexWork(ctx context.Context, work *work.Work, content string) error + IndexWork(ctx context.Context, work *domain.Work, content string) error } type weaviateWrapper struct { @@ -21,7 +21,7 @@ func NewWeaviateWrapper(client *weaviate.Client) WeaviateWrapper { return &weaviateWrapper{client: client} } -func (w *weaviateWrapper) IndexWork(ctx context.Context, work *work.Work, content string) error { +func (w *weaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { properties := map[string]interface{}{ "language": work.Language, "title": work.Title, diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 89f5cc1..64cffa6 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -11,7 +11,6 @@ import ( "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/domain/search" - "tercul/internal/domain/work" "tercul/internal/jobs/linguistics" platform_auth "tercul/internal/platform/auth" platform_config "tercul/internal/platform/config" @@ -26,7 +25,7 @@ import ( // mockSearchClient is a mock implementation of the SearchClient interface. type mockSearchClient struct{} -func (m *mockSearchClient) IndexWork(ctx context.Context, work *work.Work, pipeline string) error { +func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error { return nil } @@ -57,8 +56,8 @@ func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error { return nil } -func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { - return &work.WorkStats{}, 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 @@ -76,7 +75,7 @@ func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID return nil } func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil } -func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { return nil, nil } @@ -147,11 +146,11 @@ func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) { s.DB = db db.AutoMigrate( - &work.Work{}, &domain.User{}, &domain.Author{}, &domain.Translation{}, + &domain.Work{}, &domain.User{}, &domain.Author{}, &domain.Translation{}, &domain.Comment{}, &domain.Like{}, &domain.Bookmark{}, &domain.Collection{}, &domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{}, &domain.Source{}, &domain.Copyright{}, &domain.Monetization{}, - &work.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{}, + &domain.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{}, &domain.LanguageAnalysis{}, &domain.TextMetadata{}, &domain.ReadabilityScore{}, &domain.TranslationStats{}, &TestEntity{}, &domain.CollectionWork{}, ) @@ -238,8 +237,8 @@ func (s *IntegrationTestSuite) SetupTest() { } // CreateTestWork creates a test work with optional content -func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *work.Work { - work := &work.Work{ +func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *domain.Work { + work := &domain.Work{ Title: title, TranslatableModel: domain.TranslatableModel{ Language: language, diff --git a/internal/testutil/mock_translation_repository.go b/internal/testutil/mock_translation_repository.go index 0257370..3d900cc 100644 --- a/internal/testutil/mock_translation_repository.go +++ b/internal/testutil/mock_translation_repository.go @@ -59,6 +59,14 @@ func (m *MockTranslationRepository) ListAll(ctx context.Context) ([]domain.Trans return args.Get(0).([]domain.Translation), args.Error(1) } +func (m *MockTranslationRepository) ListByWorkIDPaginated(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + args := m.Called(ctx, workID, language, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.PaginatedResult[domain.Translation]), args.Error(1) +} + func (m *MockTranslationRepository) Count(ctx context.Context) (int64, error) { args := m.Called(ctx) return args.Get(0).(int64), args.Error(1) diff --git a/internal/testutil/mock_user_repository.go b/internal/testutil/mock_user_repository.go index 0684bba..a2dfccc 100644 --- a/internal/testutil/mock_user_repository.go +++ b/internal/testutil/mock_user_repository.go @@ -98,29 +98,78 @@ func (m *MockUserRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uin return m.Delete(ctx, id) } func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { - panic("not implemented") + start := (page - 1) * pageSize + end := start + pageSize + if start > len(m.Users) { + start = len(m.Users) + } + if end > len(m.Users) { + end = len(m.Users) + } + + paginatedUsers := m.Users[start:end] + var users []domain.User + for _, u := range paginatedUsers { + users = append(users, *u) + } + + totalCount := int64(len(m.Users)) + totalPages := int(totalCount) / pageSize + if int(totalCount)%pageSize != 0 { + totalPages++ + } + + return &domain.PaginatedResult[domain.User]{ + Items: users, + TotalCount: totalCount, + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + HasNext: page < totalPages, + HasPrev: page > 1, + }, nil } + func (m *MockUserRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) { - panic("not implemented") + // This is a mock implementation and doesn't handle options. + return m.ListAll(ctx) } + func (m *MockUserRepository) ListAll(ctx context.Context) ([]domain.User, error) { var users []domain.User - for _, u := range m.Users { - users = append(users, *u) - } - return users, nil + for _, u := range m.Users { + users = append(users, *u) + } + return users, nil } + func (m *MockUserRepository) Count(ctx context.Context) (int64, error) { return int64(len(m.Users)), nil } + func (m *MockUserRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - panic("not implemented") + // This is a mock implementation and doesn't handle options. + return m.Count(ctx) } + func (m *MockUserRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) { return m.GetByID(ctx, id) } + func (m *MockUserRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) { - panic("not implemented") + start := offset + end := start + batchSize + if start > len(m.Users) { + return []domain.User{}, nil + } + if end > len(m.Users) { + end = len(m.Users) + } + var users []domain.User + for _, u := range m.Users[start:end] { + users = append(users, *u) + } + return users, nil } func (m *MockUserRepository) Exists(ctx context.Context, id uint) (bool, error) { _, err := m.GetByID(ctx, id) diff --git a/internal/testutil/mock_weaviate_wrapper.go b/internal/testutil/mock_weaviate_wrapper.go index 78d19e9..1542f41 100644 --- a/internal/testutil/mock_weaviate_wrapper.go +++ b/internal/testutil/mock_weaviate_wrapper.go @@ -2,14 +2,14 @@ package testutil import ( "context" - "tercul/internal/domain/work" + "tercul/internal/domain" ) type MockWeaviateWrapper struct { - IndexWorkFunc func(ctx context.Context, work *work.Work, content string) error + IndexWorkFunc func(ctx context.Context, work *domain.Work, content string) error } -func (m *MockWeaviateWrapper) IndexWork(ctx context.Context, work *work.Work, content string) error { +func (m *MockWeaviateWrapper) IndexWork(ctx context.Context, work *domain.Work, content string) error { if m.IndexWorkFunc != nil { return m.IndexWorkFunc(ctx, work, content) } diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go index f88fdba..cbc1e6f 100644 --- a/internal/testutil/mock_work_repository.go +++ b/internal/testutil/mock_work_repository.go @@ -4,27 +4,26 @@ import ( "context" "tercul/internal/domain" - "tercul/internal/domain/work" "github.com/stretchr/testify/mock" "gorm.io/gorm" ) -// MockWorkRepository is a mock implementation of the work.WorkRepository interface. +// MockWorkRepository is a mock implementation of the domain.WorkRepository interface. type MockWorkRepository struct { mock.Mock } // Ensure MockWorkRepository implements the interface. -var _ work.WorkRepository = (*MockWorkRepository)(nil) +var _ domain.WorkRepository = (*MockWorkRepository)(nil) // GetByID mocks the GetByID method. -func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { +func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*work.Work), args.Error(1) + return args.Get(0).(*domain.Work), args.Error(1) } // IsAuthor mocks the IsAuthor method. @@ -34,65 +33,73 @@ func (m *MockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID } // Empty implementations for the rest of the interface to satisfy the compiler. -func (m *MockWorkRepository) Create(ctx context.Context, entity *work.Work) error { +func (m *MockWorkRepository) Create(ctx context.Context, entity *domain.Work) error { args := m.Called(ctx, entity) return args.Error(0) } -func (m *MockWorkRepository) Update(ctx context.Context, entity *work.Work) error { return nil } +func (m *MockWorkRepository) Update(ctx context.Context, entity *domain.Work) error { return nil } func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error { return nil } -func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } -func (m *MockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil } +func (m *MockWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) { return nil, nil } func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { return 0, nil } func (m *MockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } func (m *MockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } -func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { return nil, nil } -func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { +func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { return nil, nil } +func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { +func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } -func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { +func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { +func (m *MockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { +func (m *MockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { +func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { return nil, nil } -func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { +func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { +func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { return nil, nil } -func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return nil } -func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { +func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { args := m.Called(ctx, id, options) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*work.Work), args.Error(1) + return args.Get(0).(*domain.Work), args.Error(1) } -func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { +func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { return nil } func (m *MockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } -func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { +func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { return nil, nil } func (m *MockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { return 0, nil } -func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } \ No newline at end of file +func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } + +func (m *MockWorkRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { + args := m.Called(ctx, collectionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Work), args.Error(1) +} \ No newline at end of file