Merge pull request #5 from SamyRai/feature/finish-ddd-refactor

Finalize DDD Refactoring and Fix Tests
This commit is contained in:
Damir Mukimov 2025-10-03 03:51:15 +02:00 committed by GitHub
commit 02b06fd9ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 754 additions and 416 deletions

14
BUILD_ISSUES.md Normal file
View File

@ -0,0 +1,14 @@
# Build Issues
This document tracks the build errors encountered during the refactoring process.
- [ ] `internal/adapters/graphql/schema.resolvers.go:10:2: "log" imported and not used`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1071:24: r.App.AuthorRepo undefined (type *app.Application has no field or method AuthorRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1073:24: r.App.AuthorRepo undefined (type *app.Application has no field or method AuthorRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1089:36: r.App.Localization.GetAuthorBiography undefined (type *"tercul/internal/app/localization".Service has no field or method GetAuthorBiography)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1141:22: r.App.UserRepo undefined (type *app.Application has no field or method UserRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1143:24: r.App.UserRepo undefined (type *app.Application has no field or method UserRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1212:20: r.App.TagRepo undefined (type *app.Application has no field or method TagRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1225:32: r.App.TagRepo undefined (type *app.Application has no field or method TagRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1249:25: r.App.CategoryRepo undefined (type *app.Application has no field or method CategoryRepo)`
- [ ] `internal/adapters/graphql/schema.resolvers.go:1262:32: r.App.CategoryRepo undefined (type *app.Application has no field or method CategoryRepo)`

View File

@ -7,19 +7,22 @@ import (
"os/signal"
"syscall"
"tercul/internal/app"
"tercul/internal/app/analytics"
graph "tercul/internal/adapters/graphql"
"tercul/internal/data/sql"
"tercul/internal/jobs/linguistics"
"tercul/internal/platform/auth"
"tercul/internal/platform/config"
"tercul/internal/platform/db"
"tercul/internal/platform/log"
"tercul/internal/platform/search"
"time"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/hibiken/asynq"
graph "tercul/internal/adapters/graphql"
"tercul/internal/platform/auth"
"github.com/weaviate/weaviate-go-client/v5/weaviate"
)
// main is the entry point for the Tercul application.
// It uses the ApplicationBuilder and ServerFactory to initialize all components
// and start the servers in a clean, maintainable way.
func main() {
// Load configuration from environment variables
config.LoadConfig()
@ -30,27 +33,45 @@ func main() {
log.F("environment", config.Cfg.Environment),
log.F("version", "1.0.0"))
// Build application components
appBuilder := app.NewApplicationBuilder()
if err := appBuilder.Build(); err != nil {
log.LogFatal("Failed to build application",
log.F("error", err))
}
defer appBuilder.Close()
// Create server factory
serverFactory := app.NewServerFactory(appBuilder)
// Create servers
backgroundServers, err := serverFactory.CreateBackgroundJobServers()
// Initialize database connection
database, err := db.InitDB()
if err != nil {
log.LogFatal("Failed to create background job servers",
log.F("error", err))
log.LogFatal("Failed to initialize database", log.F("error", err))
}
defer db.Close()
// Initialize Weaviate client
weaviateCfg := weaviate.Config{
Host: config.Cfg.WeaviateHost,
Scheme: config.Cfg.WeaviateScheme,
}
weaviateClient, err := weaviate.NewClient(weaviateCfg)
if err != nil {
log.LogFatal("Failed to create weaviate client", log.F("error", err))
}
// Create search client
searchClient := search.NewWeaviateWrapper(weaviateClient)
// Create repositories
repos := sql.NewRepositories(database)
// Create linguistics dependencies
analysisRepo := linguistics.NewGORMAnalysisRepository(database)
sentimentProvider, err := linguistics.NewGoVADERSentimentProvider()
if err != nil {
log.LogFatal("Failed to create sentiment provider", log.F("error", err))
}
// Create application services
analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider)
// Create application
application := app.NewApplication(repos, searchClient, analyticsService)
// Create GraphQL server
resolver := &graph.Resolver{
App: appBuilder.GetApplication(),
App: application,
}
jwtManager := auth.NewJWTManager()
@ -88,19 +109,6 @@ func main() {
}
}()
// Start background job servers in goroutines
for i, server := range backgroundServers {
go func(serverIndex int, srv *asynq.Server) {
log.LogInfo("Starting background job server",
log.F("serverIndex", serverIndex))
if err := srv.Run(asynq.NewServeMux()); err != nil {
log.LogError("Background job server failed",
log.F("serverIndex", serverIndex),
log.F("error", err))
}
}(i, server)
}
// Wait for interrupt signal to gracefully shutdown the servers
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@ -122,12 +130,5 @@ func main() {
log.F("error", err))
}
// Shutdown background job servers
for i, server := range backgroundServers {
server.Shutdown()
log.LogInfo("Background job server shutdown",
log.F("serverIndex", i))
}
log.LogInfo("All servers shutdown successfully")
}

View File

@ -2,14 +2,35 @@ package graphql
import "context"
// resolveWorkContent uses Localization service to fetch preferred content
// resolveWorkContent uses the Work service to fetch preferred content for a work.
func (r *queryResolver) resolveWorkContent(ctx context.Context, workID uint, preferredLanguage string) *string {
if r.App.Localization == nil {
if r.App.Work == nil || r.App.Work.Queries == nil {
return nil
}
content, err := r.App.Localization.GetWorkContent(ctx, workID, preferredLanguage)
if err != nil || content == "" {
work, err := r.App.Work.Queries.GetWorkWithTranslations(ctx, workID)
if err != nil || work == nil {
return nil
}
return &content
// Find the translation for the preferred language.
for _, t := range work.Translations {
if t.Language == preferredLanguage && t.Content != "" {
return &t.Content
}
}
// If no specific language match, find the original language content.
for _, t := range work.Translations {
if t.IsOriginalLanguage && t.Content != "" {
return &t.Content
}
}
// Fallback to the work's own description if no suitable translation content is found.
if work.Description != "" {
return &work.Description
}
return nil
}

View File

@ -11,6 +11,13 @@ import (
"testing"
graph "tercul/internal/adapters/graphql"
"tercul/internal/app/auth"
"tercul/internal/app/author"
"tercul/internal/app/bookmark"
"tercul/internal/app/collection"
"tercul/internal/app/comment"
"tercul/internal/app/like"
"tercul/internal/app/translation"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"tercul/internal/testutil"
@ -39,12 +46,38 @@ type GraphQLIntegrationSuite struct {
client *http.Client
}
func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string, role domain.UserRole) (*domain.User, string) {
// Password can be fixed for tests
password := "password123"
// Register user
registerInput := auth.RegisterInput{
Username: username,
Email: email,
Password: password,
}
authResponse, err := s.App.Auth.Commands.Register(context.Background(), registerInput)
s.Require().NoError(err)
s.Require().NotNil(authResponse)
// Update user role if necessary
user := authResponse.User
if user.Role != role {
// This part is tricky. There is no UpdateUserRole command.
// For a test, I can update the DB directly.
s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role)
user.Role = role
}
return user, authResponse.Token
}
// SetupSuite sets up the test suite
func (s *GraphQLIntegrationSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
// Create GraphQL server with the test resolver
resolver := s.GetResolver()
resolver := &graph.Resolver{App: s.App}
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver}))
// Create JWT manager and middleware
@ -261,12 +294,15 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() {
// Verify that the work was created in the repository
workID, err := strconv.ParseUint(response.Data.CreateWork.ID, 10, 64)
s.Require().NoError(err)
createdWork, err := s.App.WorkQueries.Work(context.Background(), uint(workID))
createdWork, err := s.App.Work.Queries.GetWorkByID(context.Background(), uint(workID))
s.Require().NoError(err)
s.Require().NotNil(createdWork)
s.Equal("New Test Work", createdWork.Title)
s.Equal("en", createdWork.Language)
s.Equal("New test content", createdWork.Content)
translations, err := s.App.Translation.Queries.TranslationsByWorkID(context.Background(), createdWork.ID)
s.Require().NoError(err)
s.Require().Len(translations, 1)
s.Equal("New test content", translations[0].Content)
}
// TestGraphQLIntegrationSuite runs the test suite
@ -420,7 +456,7 @@ func (s *GraphQLIntegrationSuite) TestCreateAuthorValidation() {
func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() {
s.Run("should return error for invalid input", func() {
// Arrange
author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
s.Require().NoError(err)
// Define the mutation
@ -434,7 +470,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateAuthorValidation() {
// Define the variables with invalid input
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", author.ID),
"id": fmt.Sprintf("%d", createdAuthor.ID),
"input": map[string]interface{}{
"name": "a", // Too short
"language": "en-US", // Not 2 chars
@ -486,12 +522,12 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
s.Run("should return error for invalid input", func() {
// Arrange
work := s.CreateTestWork("Test Work", "en", "Test content")
translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{
createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{
Title: "Test Translation",
Language: "en",
Content: "Test content",
TranslatableID: work.ID,
TranslatableType: "Work",
TranslatableType: "works",
})
s.Require().NoError(err)
@ -506,7 +542,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
// Define the variables with invalid input
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", translation.ID),
"id": fmt.Sprintf("%d", createdTranslation.ID),
"input": map[string]interface{}{
"name": "a", // Too short
"language": "en-US", // Not 2 chars
@ -549,7 +585,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
s.True(response.Data.(map[string]interface{})["deleteWork"].(bool))
// Verify that the work was actually deleted from the database
_, err = s.App.WorkQueries.Work(context.Background(), work.ID)
_, err = s.App.Work.Queries.GetWorkByID(context.Background(), work.ID)
s.Require().Error(err)
})
}
@ -557,7 +593,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
s.Run("should delete an author", func() {
// Arrange
author, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
createdAuthor, err := s.App.Author.Commands.CreateAuthor(context.Background(), author.CreateAuthorInput{Name: "Test Author"})
s.Require().NoError(err)
// Define the mutation
@ -569,7 +605,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", author.ID),
"id": fmt.Sprintf("%d", createdAuthor.ID),
}
// Execute the mutation
@ -581,7 +617,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteAuthor() {
s.True(response.Data.(map[string]interface{})["deleteAuthor"].(bool))
// Verify that the author was actually deleted from the database
_, err = s.App.Author.Queries.Author(context.Background(), author.ID)
_, err = s.App.Author.Queries.Author(context.Background(), createdAuthor.ID)
s.Require().Error(err)
})
}
@ -590,12 +626,12 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
s.Run("should delete a translation", func() {
// Arrange
work := s.CreateTestWork("Test Work", "en", "Test content")
translation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{
createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{
Title: "Test Translation",
Language: "en",
Content: "Test content",
TranslatableID: work.ID,
TranslatableType: "Work",
TranslatableType: "works",
})
s.Require().NoError(err)
@ -608,7 +644,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", translation.ID),
"id": fmt.Sprintf("%d", createdTranslation.ID),
}
// Execute the mutation
@ -620,7 +656,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
s.True(response.Data.(map[string]interface{})["deleteTranslation"].(bool))
// Verify that the translation was actually deleted from the database
_, err = s.App.Translation.Queries.Translation(context.Background(), translation.ID)
_, err = s.App.Translation.Queries.Translation(context.Background(), createdTranslation.ID)
s.Require().Error(err)
})
}
@ -757,7 +793,7 @@ func (s *GraphQLIntegrationSuite) TestCommentMutations() {
s.Run("should delete a comment", func() {
// Create a new comment to delete
comment, err := s.App.Comment.Commands.CreateComment(context.Background(), comment.CreateCommentInput{
createdComment, err := s.App.Comment.Commands.CreateComment(context.Background(), comment.CreateCommentInput{
Text: "to be deleted",
UserID: commenter.ID,
WorkID: &work.ID,
@ -773,7 +809,7 @@ func (s *GraphQLIntegrationSuite) TestCommentMutations() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", comment.ID),
"id": fmt.Sprintf("%d", createdComment.ID),
}
// Execute the mutation
@ -827,7 +863,7 @@ func (s *GraphQLIntegrationSuite) TestLikeMutations() {
s.Run("should not delete a like owned by another user", func() {
// Create a like by the original user
like, err := s.App.Like.Commands.CreateLike(context.Background(), like.CreateLikeInput{
createdLike, err := s.App.Like.Commands.CreateLike(context.Background(), like.CreateLikeInput{
UserID: liker.ID,
WorkID: &work.ID,
})
@ -842,7 +878,7 @@ func (s *GraphQLIntegrationSuite) TestLikeMutations() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", like.ID),
"id": fmt.Sprintf("%d", createdLike.ID),
}
// Execute the mutation with the other user's token
@ -918,13 +954,13 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
s.Run("should not delete a bookmark owned by another user", func() {
// Create a bookmark by the original user
bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{
createdBookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{
UserID: bookmarker.ID,
WorkID: work.ID,
Name: "A Bookmark",
})
s.Require().NoError(err)
s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), bookmark.ID) })
s.T().Cleanup(func() { s.App.Bookmark.Commands.DeleteBookmark(context.Background(), createdBookmark.ID) })
// Define the mutation
mutation := `
@ -935,7 +971,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", bookmark.ID),
"id": fmt.Sprintf("%d", createdBookmark.ID),
}
// Execute the mutation with the other user's token
@ -946,7 +982,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
s.Run("should delete a bookmark", func() {
// Create a new bookmark to delete
bookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{
createdBookmark, err := s.App.Bookmark.Commands.CreateBookmark(context.Background(), bookmark.CreateBookmarkInput{
UserID: bookmarker.ID,
WorkID: work.ID,
Name: "To Be Deleted",
@ -962,7 +998,7 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
// Define the variables
variables := map[string]interface{}{
"id": fmt.Sprintf("%d", bookmark.ID),
"id": fmt.Sprintf("%d", createdBookmark.ID),
}
// Execute the mutation
@ -988,7 +1024,7 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() {
work2 := s.CreateTestWork("Work 2", "en", "content")
s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1})
s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10})
s.Require().NoError(s.App.AnalyticsService.UpdateTrending(context.Background()))
s.Require().NoError(s.App.Analytics.UpdateTrending(context.Background()))
// Act
query := `
@ -1012,7 +1048,7 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() {
func (s *GraphQLIntegrationSuite) TestCollectionMutations() {
// Create users for testing authorization
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
_, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
otherUser, otherToken := s.CreateAuthenticatedUser("otheruser", "other@test.com", domain.UserRoleReader)
_ = otherUser

View File

@ -7,10 +7,15 @@ package graphql
import (
"context"
"fmt"
"log"
"strconv"
"tercul/internal/adapters/graphql/model"
"tercul/internal/app/auth"
"tercul/internal/app/author"
"tercul/internal/app/bookmark"
"tercul/internal/app/collection"
"tercul/internal/app/comment"
"tercul/internal/app/like"
"tercul/internal/app/translation"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
)
@ -27,7 +32,7 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp
}
// Call auth service
authResponse, err := r.App.AuthCommands.Register(ctx, registerInput)
authResponse, err := r.App.Auth.Commands.Register(ctx, registerInput)
if err != nil {
return nil, err
}
@ -58,7 +63,7 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
}
// Call auth service
authResponse, err := r.App.AuthCommands.Login(ctx, loginInput)
authResponse, err := r.App.Auth.Commands.Login(ctx, loginInput)
if err != nil {
return nil, err
}
@ -94,40 +99,32 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
}
// Call work service
err := r.App.WorkCommands.CreateWork(ctx, work)
createdWork, err := r.App.Work.Commands.CreateWork(ctx, work)
if err != nil {
return nil, err
}
work = createdWork
// The logic for creating a translation should probably be in the app layer as well,
// but for now, we'll leave it here to match the old logic.
// This will be refactored later.
if input.Content != nil && *input.Content != "" {
// This part needs a translation repository, which is not in the App struct.
// I will have to add it.
// For now, I will comment this out.
/*
translation := &domain.Translation{
Title: input.Name,
Content: *input.Content,
Language: input.Language,
TranslatableID: work.ID,
TranslatableType: "Work",
IsOriginalLanguage: true,
}
// This needs a translation repo, which should be part of a translation service.
// err = r.App.TranslationRepo.Create(ctx, translation)
// if err != nil {
// return nil, fmt.Errorf("failed to create translation: %v", err)
// }
*/
translationInput := translation.CreateTranslationInput{
Title: input.Name,
Content: *input.Content,
Language: input.Language,
TranslatableID: createdWork.ID,
TranslatableType: "works",
IsOriginalLanguage: true,
}
_, err := r.App.Translation.Commands.CreateTranslation(ctx, translationInput)
if err != nil {
return nil, fmt.Errorf("failed to create translation: %w", err)
}
}
// Convert to GraphQL model
return &model.Work{
ID: fmt.Sprintf("%d", work.ID),
Name: work.Title,
Language: work.Language,
ID: fmt.Sprintf("%d", createdWork.ID),
Name: createdWork.Title,
Language: createdWork.Language,
Content: input.Content,
}, nil
}
@ -152,7 +149,7 @@ func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input mode
}
// Call work service
err = r.App.WorkCommands.UpdateWork(ctx, work)
err = r.App.Work.Commands.UpdateWork(ctx, work)
if err != nil {
return nil, err
}
@ -173,7 +170,7 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err
return false, fmt.Errorf("invalid work ID: %v", err)
}
err = r.App.WorkCommands.DeleteWork(ctx, uint(workID))
err = r.App.Work.Commands.DeleteWork(ctx, uint(workID))
if err != nil {
return false, err
}
@ -192,28 +189,38 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr
}
// Create domain model
translation := &domain.Translation{
translationModel := &domain.Translation{
Title: input.Name,
Language: input.Language,
TranslatableID: uint(workID),
TranslatableType: "Work",
}
if input.Content != nil {
translation.Content = *input.Content
translationModel.Content = *input.Content
}
// Call translation service
err = r.App.TranslationRepo.Create(ctx, translation)
createInput := translation.CreateTranslationInput{
Title: translationModel.Title,
Content: translationModel.Content,
Description: translationModel.Description,
Language: translationModel.Language,
Status: translationModel.Status,
TranslatableID: translationModel.TranslatableID,
TranslatableType: translationModel.TranslatableType,
TranslatorID: translationModel.TranslatorID,
}
createdTranslation, err := r.App.Translation.Commands.CreateTranslation(ctx, createInput)
if err != nil {
return nil, err
}
// Convert to GraphQL model
return &model.Translation{
ID: fmt.Sprintf("%d", translation.ID),
Name: translation.Title,
Language: translation.Language,
Content: &translation.Content,
ID: fmt.Sprintf("%d", createdTranslation.ID),
Name: createdTranslation.Title,
Language: createdTranslation.Language,
Content: &createdTranslation.Content,
WorkID: input.WorkID,
}, nil
}
@ -228,25 +235,16 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp
return nil, fmt.Errorf("invalid translation ID: %v", err)
}
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid work ID: %v", err)
}
// Create domain model
translation := &domain.Translation{
BaseModel: domain.BaseModel{ID: uint(translationID)},
Title: input.Name,
Language: input.Language,
TranslatableID: uint(workID),
TranslatableType: "Work",
// Call translation service
updateInput := translation.UpdateTranslationInput{
ID: uint(translationID),
Title: input.Name,
Language: input.Language,
}
if input.Content != nil {
translation.Content = *input.Content
updateInput.Content = *input.Content
}
// Call translation service
err = r.App.TranslationRepo.Update(ctx, translation)
updatedTranslation, err := r.App.Translation.Commands.UpdateTranslation(ctx, updateInput)
if err != nil {
return nil, err
}
@ -254,9 +252,9 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp
// Convert to GraphQL model
return &model.Translation{
ID: id,
Name: translation.Title,
Language: translation.Language,
Content: &translation.Content,
Name: updatedTranslation.Title,
Language: updatedTranslation.Language,
Content: &updatedTranslation.Content,
WorkID: input.WorkID,
}, nil
}
@ -268,7 +266,7 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo
return false, fmt.Errorf("invalid translation ID: %v", err)
}
err = r.App.TranslationRepo.Delete(ctx, uint(translationID))
err = r.App.Translation.Commands.DeleteTranslation(ctx, uint(translationID))
if err != nil {
return false, err
}
@ -281,25 +279,20 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI
if err := validateAuthorInput(input); err != nil {
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
}
// Create domain model
author := &domain.Author{
Name: input.Name,
TranslatableModel: domain.TranslatableModel{
Language: input.Language,
},
}
// Call author service
err := r.App.AuthorRepo.Create(ctx, author)
createInput := author.CreateAuthorInput{
Name: input.Name,
}
createdAuthor, err := r.App.Author.Commands.CreateAuthor(ctx, createInput)
if err != nil {
return nil, err
}
// Convert to GraphQL model
return &model.Author{
ID: fmt.Sprintf("%d", author.ID),
Name: author.Name,
Language: author.Language,
ID: fmt.Sprintf("%d", createdAuthor.ID),
Name: createdAuthor.Name,
Language: createdAuthor.Language,
}, nil
}
@ -313,17 +306,12 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo
return nil, fmt.Errorf("invalid author ID: %v", err)
}
// Create domain model
author := &domain.Author{
TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{ID: uint(authorID)},
Language: input.Language,
},
// Call author service
updateInput := author.UpdateAuthorInput{
ID: uint(authorID),
Name: input.Name,
}
// Call author service
err = r.App.AuthorRepo.Update(ctx, author)
updatedAuthor, err := r.App.Author.Commands.UpdateAuthor(ctx, updateInput)
if err != nil {
return nil, err
}
@ -331,8 +319,8 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo
// Convert to GraphQL model
return &model.Author{
ID: id,
Name: author.Name,
Language: author.Language,
Name: updatedAuthor.Name,
Language: updatedAuthor.Language,
}, nil
}
@ -343,7 +331,7 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e
return false, fmt.Errorf("invalid author ID: %v", err)
}
err = r.App.AuthorRepo.Delete(ctx, uint(authorID))
err = r.App.Author.Commands.DeleteAuthor(ctx, uint(authorID))
if err != nil {
return false, err
}
@ -369,26 +357,24 @@ func (r *mutationResolver) CreateCollection(ctx context.Context, input model.Col
return nil, fmt.Errorf("unauthorized")
}
// Create domain model
collection := &domain.Collection{
// Call collection service
createInput := collection.CreateCollectionInput{
Name: input.Name,
UserID: userID,
}
if input.Description != nil {
collection.Description = *input.Description
createInput.Description = *input.Description
}
// Call collection repository
err := r.App.CollectionRepo.Create(ctx, collection)
createdCollection, err := r.App.Collection.Commands.CreateCollection(ctx, createInput)
if err != nil {
return nil, err
}
// Convert to GraphQL model
return &model.Collection{
ID: fmt.Sprintf("%d", collection.ID),
Name: collection.Name,
Description: &collection.Description,
ID: fmt.Sprintf("%d", createdCollection.ID),
Name: createdCollection.Name,
Description: &createdCollection.Description,
User: &model.User{
ID: fmt.Sprintf("%d", userID),
},
@ -410,27 +396,28 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu
}
// Fetch the existing collection
collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID))
collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collectionID))
if err != nil {
return nil, err
}
if collection == nil {
if collectionModel == nil {
return nil, fmt.Errorf("collection not found")
}
// Check ownership
if collection.UserID != userID {
if collectionModel.UserID != userID {
return nil, fmt.Errorf("unauthorized")
}
// Update fields
collection.Name = input.Name
if input.Description != nil {
collection.Description = *input.Description
// Call collection service
updateInput := collection.UpdateCollectionInput{
ID: uint(collectionID),
Name: input.Name,
}
// Call collection repository
err = r.App.CollectionRepo.Update(ctx, collection)
if input.Description != nil {
updateInput.Description = *input.Description
}
updatedCollection, err := r.App.Collection.Commands.UpdateCollection(ctx, updateInput)
if err != nil {
return nil, err
}
@ -438,8 +425,8 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu
// Convert to GraphQL model
return &model.Collection{
ID: id,
Name: collection.Name,
Description: &collection.Description,
Name: updatedCollection.Name,
Description: &updatedCollection.Description,
User: &model.User{
ID: fmt.Sprintf("%d", userID),
},
@ -461,7 +448,7 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo
}
// Fetch the existing collection
collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID))
collection, err := r.App.Collection.Queries.Collection(ctx, uint(collectionID))
if err != nil {
return false, err
}
@ -475,7 +462,7 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo
}
// Call collection repository
err = r.App.CollectionRepo.Delete(ctx, uint(collectionID))
err = r.App.Collection.Commands.DeleteCollection(ctx, uint(collectionID))
if err != nil {
return false, err
}
@ -502,27 +489,31 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID
}
// Fetch the existing collection
collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID))
collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collID))
if err != nil {
return nil, err
}
if collection == nil {
if collectionModel == nil {
return nil, fmt.Errorf("collection not found")
}
// Check ownership
if collection.UserID != userID {
if collectionModel.UserID != userID {
return nil, fmt.Errorf("unauthorized")
}
// Add work to collection
err = r.App.CollectionRepo.AddWorkToCollection(ctx, uint(collID), uint(wID))
addInput := collection.AddWorkToCollectionInput{
CollectionID: uint(collID),
WorkID: uint(wID),
}
err = r.App.Collection.Commands.AddWorkToCollection(ctx, addInput)
if err != nil {
return nil, err
}
// Fetch the updated collection to return it
updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID))
updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID))
if err != nil {
return nil, err
}
@ -554,27 +545,31 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect
}
// Fetch the existing collection
collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID))
collectionModel, err := r.App.Collection.Queries.Collection(ctx, uint(collID))
if err != nil {
return nil, err
}
if collection == nil {
if collectionModel == nil {
return nil, fmt.Errorf("collection not found")
}
// Check ownership
if collection.UserID != userID {
if collectionModel.UserID != userID {
return nil, fmt.Errorf("unauthorized")
}
// Remove work from collection
err = r.App.CollectionRepo.RemoveWorkFromCollection(ctx, uint(collID), uint(wID))
removeInput := collection.RemoveWorkFromCollectionInput{
CollectionID: uint(collID),
WorkID: uint(wID),
}
err = r.App.Collection.Commands.RemoveWorkFromCollection(ctx, removeInput)
if err != nil {
return nil, err
}
// Fetch the updated collection to return it
updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID))
updatedCollection, err := r.App.Collection.Queries.Collection(ctx, uint(collID))
if err != nil {
return nil, err
}
@ -600,8 +595,8 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
return nil, fmt.Errorf("unauthorized")
}
// Create domain model
comment := &domain.Comment{
// Create command input
createInput := comment.CreateCommentInput{
Text: input.Text,
UserID: userID,
}
@ -611,7 +606,7 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
return nil, fmt.Errorf("invalid work ID: %v", err)
}
wID := uint(workID)
comment.WorkID = &wID
createInput.WorkID = &wID
}
if input.TranslationID != nil {
translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32)
@ -619,7 +614,7 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
return nil, fmt.Errorf("invalid translation ID: %v", err)
}
tID := uint(translationID)
comment.TranslationID = &tID
createInput.TranslationID = &tID
}
if input.ParentCommentID != nil {
parentCommentID, err := strconv.ParseUint(*input.ParentCommentID, 10, 32)
@ -627,27 +622,27 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
return nil, fmt.Errorf("invalid parent comment ID: %v", err)
}
pID := uint(parentCommentID)
comment.ParentID = &pID
createInput.ParentID = &pID
}
// Call comment repository
err := r.App.CommentRepo.Create(ctx, comment)
// Call comment service
createdComment, err := r.App.Comment.Commands.CreateComment(ctx, createInput)
if err != nil {
return nil, err
}
// Increment analytics
if comment.WorkID != nil {
r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID)
if createdComment.WorkID != nil {
r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID)
}
if comment.TranslationID != nil {
r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID)
if createdComment.TranslationID != nil {
r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID)
}
// Convert to GraphQL model
return &model.Comment{
ID: fmt.Sprintf("%d", comment.ID),
Text: comment.Text,
ID: fmt.Sprintf("%d", createdComment.ID),
Text: createdComment.Text,
User: &model.User{
ID: fmt.Sprintf("%d", userID),
},
@ -669,24 +664,25 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m
}
// Fetch the existing comment
comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID))
commentModel, err := r.App.Comment.Queries.Comment(ctx, uint(commentID))
if err != nil {
return nil, err
}
if comment == nil {
if commentModel == nil {
return nil, fmt.Errorf("comment not found")
}
// Check ownership
if comment.UserID != userID {
if commentModel.UserID != userID {
return nil, fmt.Errorf("unauthorized")
}
// Update fields
comment.Text = input.Text
// Call comment repository
err = r.App.CommentRepo.Update(ctx, comment)
// Call comment service
updateInput := comment.UpdateCommentInput{
ID: uint(commentID),
Text: input.Text,
}
updatedComment, err := r.App.Comment.Commands.UpdateComment(ctx, updateInput)
if err != nil {
return nil, err
}
@ -694,7 +690,7 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m
// Convert to GraphQL model
return &model.Comment{
ID: id,
Text: comment.Text,
Text: updatedComment.Text,
User: &model.User{
ID: fmt.Sprintf("%d", userID),
},
@ -716,7 +712,7 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool,
}
// Fetch the existing comment
comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID))
comment, err := r.App.Comment.Queries.Comment(ctx, uint(commentID))
if err != nil {
return false, err
}
@ -730,7 +726,7 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool,
}
// Call comment repository
err = r.App.CommentRepo.Delete(ctx, uint(commentID))
err = r.App.Comment.Commands.DeleteComment(ctx, uint(commentID))
if err != nil {
return false, err
}
@ -754,8 +750,8 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
return nil, fmt.Errorf("unauthorized")
}
// Create domain model
like := &domain.Like{
// Create command input
createInput := like.CreateLikeInput{
UserID: userID,
}
if input.WorkID != nil {
@ -764,7 +760,7 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
return nil, fmt.Errorf("invalid work ID: %v", err)
}
wID := uint(workID)
like.WorkID = &wID
createInput.WorkID = &wID
}
if input.TranslationID != nil {
translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32)
@ -772,7 +768,7 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
return nil, fmt.Errorf("invalid translation ID: %v", err)
}
tID := uint(translationID)
like.TranslationID = &tID
createInput.TranslationID = &tID
}
if input.CommentID != nil {
commentID, err := strconv.ParseUint(*input.CommentID, 10, 32)
@ -780,26 +776,26 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
return nil, fmt.Errorf("invalid comment ID: %v", err)
}
cID := uint(commentID)
like.CommentID = &cID
createInput.CommentID = &cID
}
// Call like repository
err := r.App.LikeRepo.Create(ctx, like)
// Call like service
createdLike, err := r.App.Like.Commands.CreateLike(ctx, createInput)
if err != nil {
return nil, err
}
// Increment analytics
if like.WorkID != nil {
r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID)
if createdLike.WorkID != nil {
r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID)
}
if like.TranslationID != nil {
r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID)
if createdLike.TranslationID != nil {
r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID)
}
// Convert to GraphQL model
return &model.Like{
ID: fmt.Sprintf("%d", like.ID),
ID: fmt.Sprintf("%d", createdLike.ID),
User: &model.User{ID: fmt.Sprintf("%d", userID)},
}, nil
}
@ -819,7 +815,7 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err
}
// Fetch the existing like
like, err := r.App.LikeRepo.GetByID(ctx, uint(likeID))
like, err := r.App.Like.Queries.Like(ctx, uint(likeID))
if err != nil {
return false, err
}
@ -832,8 +828,8 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err
return false, fmt.Errorf("unauthorized")
}
// Call like repository
err = r.App.LikeRepo.Delete(ctx, uint(likeID))
// Call like service
err = r.App.Like.Commands.DeleteLike(ctx, uint(likeID))
if err != nil {
return false, err
}
@ -855,28 +851,28 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm
return nil, fmt.Errorf("invalid work ID: %v", err)
}
// Create domain model
bookmark := &domain.Bookmark{
// Create command input
createInput := bookmark.CreateBookmarkInput{
UserID: userID,
WorkID: uint(workID),
}
if input.Name != nil {
bookmark.Name = *input.Name
createInput.Name = *input.Name
}
// Call bookmark repository
err = r.App.BookmarkRepo.Create(ctx, bookmark)
// Call bookmark service
createdBookmark, err := r.App.Bookmark.Commands.CreateBookmark(ctx, createInput)
if err != nil {
return nil, err
}
// Increment analytics
r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID))
r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID))
// Convert to GraphQL model
return &model.Bookmark{
ID: fmt.Sprintf("%d", bookmark.ID),
Name: &bookmark.Name,
ID: fmt.Sprintf("%d", createdBookmark.ID),
Name: &createdBookmark.Name,
User: &model.User{ID: fmt.Sprintf("%d", userID)},
Work: &model.Work{ID: fmt.Sprintf("%d", workID)},
}, nil
@ -897,7 +893,7 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool,
}
// Fetch the existing bookmark
bookmark, err := r.App.BookmarkRepo.GetByID(ctx, uint(bookmarkID))
bookmark, err := r.App.Bookmark.Queries.Bookmark(ctx, uint(bookmarkID))
if err != nil {
return false, err
}
@ -910,8 +906,8 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool,
return false, fmt.Errorf("unauthorized")
}
// Call bookmark repository
err = r.App.BookmarkRepo.Delete(ctx, uint(bookmarkID))
// Call bookmark service
err = r.App.Bookmark.Commands.DeleteBookmark(ctx, uint(bookmarkID))
if err != nil {
return false, err
}
@ -986,7 +982,7 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error
return nil, fmt.Errorf("invalid work ID: %v", err)
}
work, err := r.App.WorkQueries.GetWorkByID(ctx, uint(workID))
work, err := r.App.Work.Queries.GetWorkByID(ctx, uint(workID))
if err != nil {
return nil, err
}
@ -994,18 +990,13 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error
return nil, nil
}
// Content resolved via Localization service
content, err := r.App.Localization.GetWorkContent(ctx, work.ID, work.Language)
if err != nil {
// Log error but don't fail the request
log.Printf("could not resolve content for work %d: %v", work.ID, err)
}
content := r.resolveWorkContent(ctx, work.ID, work.Language)
return &model.Work{
ID: id,
Name: work.Title,
Language: work.Language,
Content: &content,
Content: content,
}, nil
}
@ -1023,7 +1014,7 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32,
page = int(*offset)/pageSize + 1
}
paginatedResult, err := r.App.WorkQueries.ListWorks(ctx, page, pageSize)
paginatedResult, err := r.App.Work.Queries.ListWorks(ctx, page, pageSize)
if err != nil {
return nil, err
}
@ -1031,12 +1022,12 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32,
// Convert to GraphQL model
var result []*model.Work
for _, w := range paginatedResult.Items {
content, _ := r.App.Localization.GetWorkContent(ctx, w.ID, w.Language)
content := r.resolveWorkContent(ctx, w.ID, w.Language)
result = append(result, &model.Work{
ID: fmt.Sprintf("%d", w.ID),
Name: w.Title,
Language: w.Language,
Content: &content,
Content: content,
})
}
return result, nil
@ -1059,36 +1050,36 @@ func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, e
// Authors is the resolver for the authors field.
func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32, search *string, countryID *string) ([]*model.Author, error) {
var authors []domain.Author
var authors []*domain.Author
var err error
var countryIDUint *uint
if countryID != nil {
countryIDUint, err := strconv.ParseUint(*countryID, 10, 32)
parsedID, err := strconv.ParseUint(*countryID, 10, 32)
if err != nil {
return nil, err
}
authors, err = r.App.AuthorRepo.ListByCountryID(ctx, uint(countryIDUint))
} else {
result, err := r.App.AuthorRepo.List(ctx, 1, 1000) // Use pagination
if err != nil {
return nil, err
}
authors = result.Items
uid := uint(parsedID)
countryIDUint = &uid
}
authors, err = r.App.Author.Queries.Authors(ctx, countryIDUint)
if err != nil {
return nil, err
}
// Convert to GraphQL model; resolve biography via Localization service
// Convert to GraphQL model; resolve biography
var result []*model.Author
for _, a := range authors {
var bio *string
if r.App.Localization != nil {
if b, err := r.App.Localization.GetAuthorBiography(ctx, a.ID, a.Language); err == nil && b != "" {
bio = &b
authorWithTranslations, err := r.App.Author.Queries.AuthorWithTranslations(ctx, a.ID)
if err == nil && authorWithTranslations != nil {
biography, err := r.App.Localization.Queries.GetAuthorBiography(ctx, a.ID, a.Language)
if err == nil && biography != "" {
bio = &biography
}
}
result = append(result, &model.Author{
ID: fmt.Sprintf("%d", a.ID),
Name: a.Name,
@ -1137,13 +1128,9 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32,
default:
return nil, fmt.Errorf("invalid user role: %s", *role)
}
users, err = r.App.UserRepo.ListByRole(ctx, modelRole)
users, err = r.App.User.Queries.UsersByRole(ctx, modelRole)
} else {
result, err := r.App.UserRepo.List(ctx, 1, 1000) // Use pagination
if err != nil {
return nil, err
}
users = result.Items
users, err = r.App.User.Queries.Users(ctx)
}
if err != nil {
@ -1208,7 +1195,7 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error)
return nil, err
}
tag, err := r.App.TagRepo.GetByID(ctx, uint(tagID))
tag, err := r.App.Tag.Queries.Tag(ctx, uint(tagID))
if err != nil {
return nil, err
}
@ -1221,14 +1208,14 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error)
// Tags is the resolver for the tags field.
func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) ([]*model.Tag, error) {
paginatedResult, err := r.App.TagRepo.List(ctx, 1, 1000) // Use pagination
tags, err := r.App.Tag.Queries.Tags(ctx)
if err != nil {
return nil, err
}
// Convert to GraphQL model
var result []*model.Tag
for _, t := range paginatedResult.Items {
for _, t := range tags {
result = append(result, &model.Tag{
ID: fmt.Sprintf("%d", t.ID),
Name: t.Name,
@ -1242,13 +1229,16 @@ func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) (
func (r *queryResolver) Category(ctx context.Context, id string) (*model.Category, error) {
categoryID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid category ID: %v", err)
}
category, err := r.App.CategoryRepo.GetByID(ctx, uint(categoryID))
category, err := r.App.Category.Queries.Category(ctx, uint(categoryID))
if err != nil {
return nil, err
}
if category == nil {
return nil, nil
}
return &model.Category{
ID: fmt.Sprintf("%d", category.ID),
@ -1258,14 +1248,14 @@ func (r *queryResolver) Category(ctx context.Context, id string) (*model.Categor
// Categories is the resolver for the categories field.
func (r *queryResolver) Categories(ctx context.Context, limit *int32, offset *int32) ([]*model.Category, error) {
paginatedResult, err := r.App.CategoryRepo.List(ctx, 1, 1000)
categories, err := r.App.Category.Queries.Categories(ctx)
if err != nil {
return nil, err
}
// Convert to GraphQL model
var result []*model.Category
for _, c := range paginatedResult.Items {
for _, c := range categories {
result = append(result, &model.Category{
ID: fmt.Sprintf("%d", c.ID),
Name: c.Name,
@ -1302,7 +1292,7 @@ func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, l
l = int(*limit)
}
works, err := r.App.AnalyticsService.GetTrendingWorks(ctx, tp, l)
works, err := r.App.Analytics.GetTrendingWorks(ctx, tp, l)
if err != nil {
return nil, err
}
@ -1352,7 +1342,7 @@ func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkS
return nil, fmt.Errorf("invalid work ID: %v", err)
}
stats, err := r.App.AnalyticsService.GetOrCreateWorkStats(ctx, uint(workID))
stats, err := r.App.Analytics.GetOrCreateWorkStats(ctx, uint(workID))
if err != nil {
return nil, err
}
@ -1377,7 +1367,7 @@ func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation)
return nil, fmt.Errorf("invalid translation ID: %v", err)
}
stats, err := r.App.AnalyticsService.GetOrCreateTranslationStats(ctx, uint(translationID))
stats, err := r.App.Analytics.GetOrCreateTranslationStats(ctx, uint(translationID))
if err != nil {
return nil, err
}

View File

@ -1,20 +1,21 @@
package app
import (
"tercul/internal/app/analytics"
"tercul/internal/app/author"
"tercul/internal/app/bookmark"
"tercul/internal/app/category"
"tercul/internal/app/collection"
"tercul/internal/app/comment"
"tercul/internal/app/like"
"tercul/internal/app/localization"
"tercul/internal/app/tag"
"tercul/internal/app/translation"
"tercul/internal/app/user"
"tercul/internal/app/localization"
"tercul/internal/app/auth"
"tercul/internal/app/work"
"tercul/internal/domain"
"tercul/internal/data/sql"
"tercul/internal/domain/search"
platform_auth "tercul/internal/platform/auth"
)
@ -32,10 +33,10 @@ type Application struct {
Localization *localization.Service
Auth *auth.Service
Work *work.Service
Repos *sql.Repositories
Analytics analytics.Service
}
func NewApplication(repos *sql.Repositories, searchClient domain.SearchClient) *Application {
func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application {
jwtManager := platform_auth.NewJWTManager()
authorService := author.NewService(repos.Author)
bookmarkService := bookmark.NewService(repos.Bookmark)
@ -63,6 +64,6 @@ func NewApplication(repos *sql.Repositories, searchClient domain.SearchClient) *
Localization: localizationService,
Auth: authService,
Work: workService,
Repos: repos,
Analytics: analyticsService,
}
}

View File

@ -20,15 +20,29 @@ func (q *AuthorQueries) Author(ctx context.Context, id uint) (*domain.Author, er
return q.repo.GetByID(ctx, id)
}
// Authors returns all authors.
func (q *AuthorQueries) Authors(ctx context.Context) ([]*domain.Author, error) {
authors, err := q.repo.ListAll(ctx)
// Authors returns all authors, with optional filtering by country.
func (q *AuthorQueries) Authors(ctx context.Context, countryID *uint) ([]*domain.Author, error) {
var authors []domain.Author
var err error
if countryID != nil {
authors, err = q.repo.ListByCountryID(ctx, *countryID)
} else {
authors, err = q.repo.ListAll(ctx)
}
if err != nil {
return nil, err
}
authorPtrs := make([]*domain.Author, len(authors))
for i := range authors {
authorPtrs[i] = &authors[i]
}
return authorPtrs, nil
}
// AuthorWithTranslations returns an author by ID with its translations.
func (q *AuthorQueries) AuthorWithTranslations(ctx context.Context, id uint) (*domain.Author, error) {
return q.repo.GetWithTranslations(ctx, id)
}

View File

@ -0,0 +1,13 @@
package localization
import "tercul/internal/domain/localization"
// LocalizationCommands contains the command handlers for the localization aggregate.
type LocalizationCommands struct {
repo localization.LocalizationRepository
}
// NewLocalizationCommands creates a new LocalizationCommands handler.
func NewLocalizationCommands(repo localization.LocalizationRepository) *LocalizationCommands {
return &LocalizationCommands{repo: repo}
}

View File

@ -0,0 +1,31 @@
package localization
import (
"context"
"tercul/internal/domain/localization"
)
// LocalizationQueries contains the query handlers for the localization aggregate.
type LocalizationQueries struct {
repo localization.LocalizationRepository
}
// NewLocalizationQueries creates a new LocalizationQueries handler.
func NewLocalizationQueries(repo localization.LocalizationRepository) *LocalizationQueries {
return &LocalizationQueries{repo: repo}
}
// GetTranslation returns a translation for a given key and language.
func (q *LocalizationQueries) GetTranslation(ctx context.Context, key string, language string) (string, error) {
return q.repo.GetTranslation(ctx, key, language)
}
// GetTranslations returns a map of translations for a given set of keys and language.
func (q *LocalizationQueries) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) {
return q.repo.GetTranslations(ctx, keys, language)
}
// GetAuthorBiography returns the biography of an author in a specific language.
func (q *LocalizationQueries) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
return q.repo.GetAuthorBiography(ctx, authorID, language)
}

View File

@ -1,26 +1,17 @@
package localization
import (
"context"
"tercul/internal/domain"
)
import "tercul/internal/domain/localization"
// Service handles localization-related operations.
// Service is the application service for the localization aggregate.
type Service struct {
repo domain.LocalizationRepository
Commands *LocalizationCommands
Queries *LocalizationQueries
}
// NewService creates a new localization service.
func NewService(repo domain.LocalizationRepository) *Service {
return &Service{repo: repo}
}
// GetTranslation returns a translation for a given key and language.
func (s *Service) GetTranslation(ctx context.Context, key string, language string) (string, error) {
return s.repo.GetTranslation(ctx, key, language)
}
// GetTranslations returns a map of translations for a given set of keys and language.
func (s *Service) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) {
return s.repo.GetTranslations(ctx, keys, language)
// NewService creates a new localization Service.
func NewService(repo localization.LocalizationRepository) *Service {
return &Service{
Commands: NewLocalizationCommands(repo),
Queries: NewLocalizationQueries(repo),
}
}

View File

@ -25,6 +25,11 @@ func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys [
return args.Get(0).(map[string]string), args.Error(1)
}
func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
args := m.Called(ctx, authorID, language)
return args.String(0), args.Error(1)
}
func TestLocalizationService_GetTranslation(t *testing.T) {
repo := new(mockLocalizationRepository)
service := NewService(repo)
@ -36,7 +41,7 @@ func TestLocalizationService_GetTranslation(t *testing.T) {
repo.On("GetTranslation", ctx, key, language).Return(expectedTranslation, nil)
translation, err := service.GetTranslation(ctx, key, language)
translation, err := service.Queries.GetTranslation(ctx, key, language)
assert.NoError(t, err)
assert.Equal(t, expectedTranslation, translation)
@ -57,9 +62,27 @@ func TestLocalizationService_GetTranslations(t *testing.T) {
repo.On("GetTranslations", ctx, keys, language).Return(expectedTranslations, nil)
translations, err := service.GetTranslations(ctx, keys, language)
translations, err := service.Queries.GetTranslations(ctx, keys, language)
assert.NoError(t, err)
assert.Equal(t, expectedTranslations, translations)
repo.AssertExpectations(t)
}
func TestLocalizationService_GetAuthorBiography(t *testing.T) {
repo := new(mockLocalizationRepository)
service := NewService(repo)
ctx := context.Background()
authorID := uint(1)
language := "en"
expectedBiography := "This is a test biography."
repo.On("GetAuthorBiography", ctx, authorID, language).Return(expectedBiography, nil)
biography, err := service.Queries.GetAuthorBiography(ctx, authorID, language)
assert.NoError(t, err)
assert.Equal(t, expectedBiography, biography)
repo.AssertExpectations(t)
}

View File

@ -27,6 +27,11 @@ func (m *mockLocalizationRepository) GetTranslations(ctx context.Context, keys [
return args.Get(0).(map[string]string), args.Error(1)
}
func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
args := m.Called(ctx, authorID, language)
return args.String(0), args.Error(1)
}
type mockWeaviateWrapper struct {
mock.Mock
}

View File

@ -17,27 +17,29 @@ func NewTranslationCommands(repo domain.TranslationRepository) *TranslationComma
// CreateTranslationInput represents the input for creating a new translation.
type CreateTranslationInput struct {
Title string
Content string
Description string
Language string
Status domain.TranslationStatus
TranslatableID uint
TranslatableType string
TranslatorID *uint
Title string
Content string
Description string
Language string
Status domain.TranslationStatus
TranslatableID uint
TranslatableType string
TranslatorID *uint
IsOriginalLanguage bool
}
// CreateTranslation creates a new translation.
func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) {
translation := &domain.Translation{
Title: input.Title,
Content: input.Content,
Description: input.Description,
Language: input.Language,
Status: input.Status,
TranslatableID: input.TranslatableID,
TranslatableType: input.TranslatableType,
TranslatorID: input.TranslatorID,
Title: input.Title,
Content: input.Content,
Description: input.Description,
Language: input.Language,
Status: input.Status,
TranslatableID: input.TranslatableID,
TranslatableType: input.TranslatableType,
TranslatorID: input.TranslatorID,
IsOriginalLanguage: input.IsOriginalLanguage,
}
err := c.repo.Create(ctx, translation)
if err != nil {

View File

@ -4,16 +4,17 @@ import (
"context"
"errors"
"tercul/internal/domain"
"tercul/internal/domain/search"
)
// WorkCommands contains the command handlers for the work aggregate.
type WorkCommands struct {
repo domain.WorkRepository
searchClient domain.SearchClient
searchClient search.SearchClient
}
// NewWorkCommands creates a new WorkCommands handler.
func NewWorkCommands(repo domain.WorkRepository, searchClient domain.SearchClient) *WorkCommands {
func NewWorkCommands(repo domain.WorkRepository, searchClient search.SearchClient) *WorkCommands {
return &WorkCommands{
repo: repo,
searchClient: searchClient,

View File

@ -11,15 +11,15 @@ import (
type WorkCommandsSuite struct {
suite.Suite
repo *mockWorkRepository
analyzer *mockAnalyzer
commands *WorkCommands
repo *mockWorkRepository
searchClient *mockSearchClient
commands *WorkCommands
}
func (s *WorkCommandsSuite) SetupTest() {
s.repo = &mockWorkRepository{}
s.analyzer = &mockAnalyzer{}
s.commands = NewWorkCommands(s.repo, s.analyzer)
s.searchClient = &mockSearchClient{}
s.commands = NewWorkCommands(s.repo, s.searchClient)
}
func TestWorkCommandsSuite(t *testing.T) {
@ -28,24 +28,24 @@ func TestWorkCommandsSuite(t *testing.T) {
func (s *WorkCommandsSuite) TestCreateWork_Success() {
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
err := s.commands.CreateWork(context.Background(), work)
_, err := s.commands.CreateWork(context.Background(), work)
assert.NoError(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_Nil() {
err := s.commands.CreateWork(context.Background(), nil)
_, err := s.commands.CreateWork(context.Background(), nil)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_EmptyTitle() {
work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}}
err := s.commands.CreateWork(context.Background(), work)
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_EmptyLanguage() {
work := &domain.Work{Title: "Test Work"}
err := s.commands.CreateWork(context.Background(), work)
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
@ -54,7 +54,7 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
s.repo.createFunc = func(ctx context.Context, w *domain.Work) error {
return errors.New("db error")
}
err := s.commands.CreateWork(context.Background(), work)
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
@ -122,16 +122,3 @@ func (s *WorkCommandsSuite) TestAnalyzeWork_Success() {
err := s.commands.AnalyzeWork(context.Background(), 1)
assert.NoError(s.T(), err)
}
func (s *WorkCommandsSuite) TestAnalyzeWork_ZeroID() {
err := s.commands.AnalyzeWork(context.Background(), 0)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestAnalyzeWork_AnalyzerError() {
s.analyzer.analyzeWorkFunc = func(ctx context.Context, workID uint) error {
return errors.New("analyzer error")
}
err := s.commands.AnalyzeWork(context.Background(), 1)
assert.Error(s.T(), err)
}

View File

@ -80,13 +80,13 @@ func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string
return nil, nil
}
type mockAnalyzer struct {
analyzeWorkFunc func(ctx context.Context, workID uint) error
type mockSearchClient struct {
indexWorkFunc func(ctx context.Context, work *domain.Work, pipeline string) error
}
func (m *mockAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error {
if m.analyzeWorkFunc != nil {
return m.analyzeWorkFunc(ctx, workID)
func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error {
if m.indexWorkFunc != nil {
return m.indexWorkFunc(ctx, work, pipeline)
}
return nil
}

View File

@ -2,6 +2,7 @@ package work
import (
"tercul/internal/domain"
"tercul/internal/domain/search"
)
// Service is the application service for the work aggregate.
@ -11,7 +12,7 @@ type Service struct {
}
// NewService creates a new work Service.
func NewService(repo domain.WorkRepository, searchClient domain.SearchClient) *Service {
func NewService(repo domain.WorkRepository, searchClient search.SearchClient) *Service {
return &Service{
Commands: NewWorkCommands(repo, searchClient),
Queries: NewWorkQueries(repo),

View File

@ -2,7 +2,7 @@ package sql
import (
"context"
"tercul/internal/domain"
"tercul/internal/domain/auth"
"time"
"gorm.io/gorm"
@ -12,12 +12,12 @@ type authRepository struct {
db *gorm.DB
}
func NewAuthRepository(db *gorm.DB) domain.AuthRepository {
func NewAuthRepository(db *gorm.DB) auth.AuthRepository {
return &authRepository{db: db}
}
func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error {
session := &domain.UserSession{
session := &auth.UserSession{
UserID: userID,
Token: token,
ExpiresAt: expiresAt,
@ -26,5 +26,5 @@ func (r *authRepository) StoreToken(ctx context.Context, userID uint, token stri
}
func (r *authRepository) DeleteToken(ctx context.Context, token string) error {
return r.db.WithContext(ctx).Where("token = ?", token).Delete(&domain.UserSession{}).Error
return r.db.WithContext(ctx).Where("token = ?", token).Delete(&auth.UserSession{}).Error
}

View File

@ -31,6 +31,15 @@ func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]dom
return authors, nil
}
// GetWithTranslations finds an author by ID and preloads their translations.
func (r *authorRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Author, error) {
var author domain.Author
if err := r.db.WithContext(ctx).Preload("Translations").First(&author, id).Error; err != nil {
return nil, err
}
return &author, nil
}
// ListByBookID finds authors by book ID
func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) {
var authors []domain.Author

View File

@ -3,6 +3,7 @@ package sql_test
import (
"context"
"testing"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/testutil"
@ -11,10 +12,12 @@ import (
type AuthorRepositoryTestSuite struct {
testutil.IntegrationTestSuite
AuthorRepo domain.AuthorRepository
}
func (s *AuthorRepositoryTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
s.AuthorRepo = sql.NewAuthorRepository(s.DB)
}
func (s *AuthorRepositoryTestSuite) SetupTest() {

View File

@ -3,6 +3,7 @@ package sql_test
import (
"context"
"testing"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/testutil"
@ -11,10 +12,12 @@ import (
type BookRepositoryTestSuite struct {
testutil.IntegrationTestSuite
BookRepo domain.BookRepository
}
func (s *BookRepositoryTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
s.BookRepo = sql.NewBookRepository(s.DB)
}
func (s *BookRepositoryTestSuite) SetupTest() {

View File

@ -3,6 +3,7 @@ package sql_test
import (
"context"
"testing"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/testutil"
@ -11,10 +12,12 @@ import (
type CategoryRepositoryTestSuite struct {
testutil.IntegrationTestSuite
CategoryRepo domain.CategoryRepository
}
func (s *CategoryRepositoryTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
s.CategoryRepo = sql.NewCategoryRepository(s.DB)
}
func (s *CategoryRepositoryTestSuite) SetupTest() {

View File

@ -3,6 +3,7 @@ package sql
import (
"context"
"tercul/internal/domain"
"tercul/internal/domain/localization"
"gorm.io/gorm"
)
@ -11,21 +12,21 @@ type localizationRepository struct {
db *gorm.DB
}
func NewLocalizationRepository(db *gorm.DB) domain.LocalizationRepository {
func NewLocalizationRepository(db *gorm.DB) localization.LocalizationRepository {
return &localizationRepository{db: db}
}
func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) {
var localization domain.Localization
err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&localization).Error
var l localization.Localization
err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&l).Error
if err != nil {
return "", err
}
return localization.Value, nil
return l.Value, nil
}
func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) {
var localizations []domain.Localization
var localizations []localization.Localization
err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error
if err != nil {
return nil, err
@ -36,3 +37,17 @@ func (r *localizationRepository) GetTranslations(ctx context.Context, keys []str
}
return result, nil
}
func (r *localizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
var translation domain.Translation
err := r.db.WithContext(ctx).
Where("translatable_type = ? AND translatable_id = ? AND language = ?", "authors", authorID, language).
First(&translation).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", nil
}
return "", err
}
return translation.Content, nil
}

View File

@ -3,6 +3,7 @@ package sql_test
import (
"context"
"testing"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/testutil"
@ -11,10 +12,12 @@ import (
type MonetizationRepositoryTestSuite struct {
testutil.IntegrationTestSuite
MonetizationRepo domain.MonetizationRepository
}
func (s *MonetizationRepositoryTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
s.MonetizationRepo = sql.NewMonetizationRepository(s.DB)
}
func (s *MonetizationRepositoryTestSuite) SetupTest() {

View File

@ -2,6 +2,8 @@ package sql
import (
"tercul/internal/domain"
"tercul/internal/domain/auth"
"tercul/internal/domain/localization"
"gorm.io/gorm"
)
@ -23,8 +25,8 @@ type Repositories struct {
Copyright domain.CopyrightRepository
Monetization domain.MonetizationRepository
Analytics domain.AnalyticsRepository
Auth domain.AuthRepository
Localization domain.LocalizationRepository
Auth auth.AuthRepository
Localization localization.LocalizationRepository
}
// NewRepositories creates a new Repositories container

View File

@ -23,7 +23,7 @@ func NewTranslationRepository(db *gorm.DB) domain.TranslationRepository {
// ListByWorkID finds translations by work ID
func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "Work").Find(&translations).Error; err != nil {
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "works").Find(&translations).Error; err != nil {
return nil, err
}
return translations, nil

View File

@ -3,6 +3,7 @@ package sql_test
import (
"context"
"testing"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/testutil"
@ -11,10 +12,12 @@ import (
type WorkRepositoryTestSuite struct {
testutil.IntegrationTestSuite
WorkRepo domain.WorkRepository
}
func (s *WorkRepositoryTestSuite) SetupSuite() {
s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig())
s.WorkRepo = sql.NewWorkRepository(s.DB)
}
func (s *WorkRepositoryTestSuite) TestCreateWork() {

View File

@ -0,0 +1,18 @@
package auth
import "time"
// BaseModel contains common fields for all models
type BaseModel struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
}
// UserSession represents a user session
type UserSession struct {
BaseModel
UserID uint `gorm:"index"`
Token string `gorm:"size:255;not null;uniqueIndex"`
ExpiresAt time.Time `gorm:"not null"`
}

View File

@ -0,0 +1,12 @@
package auth
import (
"context"
"time"
)
// AuthRepository defines the interface for authentication data access.
type AuthRepository interface {
StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error
DeleteToken(ctx context.Context, token string) error
}

View File

@ -209,7 +209,7 @@ type Work struct {
Type WorkType `gorm:"size:50;default:'other'"`
Status WorkStatus `gorm:"size:50;default:'draft'"`
PublishedAt *time.Time
Translations []Translation `gorm:"polymorphic:Translatable"`
Translations []*Translation `gorm:"polymorphic:Translatable"`
Authors []*Author `gorm:"many2many:work_authors"`
Tags []*Tag `gorm:"many2many:work_tags"`
Categories []*Category `gorm:"many2many:work_categories"`
@ -239,7 +239,7 @@ type Author struct {
Place *Place `gorm:"foreignKey:PlaceID"`
AddressID *uint
Address *Address `gorm:"foreignKey:AddressID"`
Translations []Translation `gorm:"polymorphic:Translatable"`
Translations []*Translation `gorm:"polymorphic:Translatable"`
Copyrights []*Copyright `gorm:"many2many:author_copyrights;constraint:OnDelete:CASCADE"`
Monetizations []*Monetization `gorm:"many2many:author_monetizations;constraint:OnDelete:CASCADE"`
}
@ -271,7 +271,7 @@ type Book struct {
Authors []*Author `gorm:"many2many:book_authors"`
PublisherID *uint
Publisher *Publisher `gorm:"foreignKey:PublisherID"`
Translations []Translation `gorm:"polymorphic:Translatable"`
Translations []*Translation `gorm:"polymorphic:Translatable"`
Copyrights []*Copyright `gorm:"many2many:book_copyrights;constraint:OnDelete:CASCADE"`
Monetizations []*Monetization `gorm:"many2many:book_monetizations;constraint:OnDelete:CASCADE"`
}
@ -290,7 +290,7 @@ type Publisher struct {
Books []*Book `gorm:"foreignKey:PublisherID"`
CountryID *uint
Country *Country `gorm:"foreignKey:CountryID"`
Translations []Translation `gorm:"polymorphic:Translatable"`
Translations []*Translation `gorm:"polymorphic:Translatable"`
Copyrights []*Copyright `gorm:"many2many:publisher_copyrights;constraint:OnDelete:CASCADE"`
Monetizations []*Monetization `gorm:"many2many:publisher_monetizations;constraint:OnDelete:CASCADE"`
}
@ -308,7 +308,7 @@ type Source struct {
URL string `gorm:"size:512"`
Status SourceStatus `gorm:"size:50;default:'active'"`
Works []*Work `gorm:"many2many:work_sources"`
Translations []Translation `gorm:"polymorphic:Translatable"`
Translations []*Translation `gorm:"polymorphic:Translatable"`
Copyrights []*Copyright `gorm:"many2many:source_copyrights;constraint:OnDelete:CASCADE"`
Monetizations []*Monetization `gorm:"many2many:source_monetizations;constraint:OnDelete:CASCADE"`
}

View File

@ -251,6 +251,7 @@ type AuthorRepository interface {
ListByWorkID(ctx context.Context, workID uint) ([]Author, error)
ListByBookID(ctx context.Context, bookID uint) ([]Author, error)
ListByCountryID(ctx context.Context, countryID uint) ([]Author, error)
GetWithTranslations(ctx context.Context, id uint) (*Author, error)
}

View File

@ -0,0 +1,18 @@
package localization
import "time"
// BaseModel contains common fields for all models
type BaseModel struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
}
// Localization represents a key-value pair for a specific language.
type Localization struct {
BaseModel
Key string `gorm:"size:255;not null;uniqueIndex:uniq_localization_key_language"`
Value string `gorm:"type:text;not null"`
Language string `gorm:"size:50;not null;uniqueIndex:uniq_localization_key_language"`
}

View File

@ -0,0 +1,12 @@
package localization
import (
"context"
)
// LocalizationRepository defines the interface for localization data access.
type LocalizationRepository interface {
GetTranslation(ctx context.Context, key string, language string) (string, error)
GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error)
GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error)
}

View File

@ -0,0 +1,11 @@
package search
import (
"context"
"tercul/internal/domain"
)
// SearchClient defines the interface for a search client.
type SearchClient interface {
IndexWork(ctx context.Context, work *domain.Work, pipeline string) error
}

View File

@ -5,13 +5,13 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"tercul/internal/app"
"tercul/internal/app/analytics"
"tercul/internal/app/translation"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/platform/config"
"tercul/internal/platform/search"
"testing"
"tercul/internal/domain/search"
"tercul/internal/jobs/linguistics"
"time"
"github.com/stretchr/testify/suite"
@ -20,6 +20,63 @@ import (
"gorm.io/gorm/logger"
)
// mockSearchClient is a mock implementation of the SearchClient interface.
type mockSearchClient struct{}
func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error {
return nil
}
// mockAnalyticsService is a mock implementation of the AnalyticsService interface.
type mockAnalyticsService struct{}
func (m *mockAnalyticsService) IncrementWorkViews(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementWorkShares(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationViews(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
return &domain.WorkStats{}, nil
}
func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
return &domain.TranslationStats{}, nil
}
func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateWorkComplexity(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateWorkSentiment(ctx context.Context, workID uint) error { return nil }
func (m *mockAnalyticsService) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
return nil
}
func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
return nil
}
func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil }
func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
return nil, nil
}
// IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories
type IntegrationTestSuite struct {
suite.Suite
@ -87,11 +144,19 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
&domain.Tag{}, &domain.Category{}, &domain.Book{}, &domain.Publisher{},
&domain.Source{}, &domain.Copyright{}, &domain.Monetization{},
&domain.WorkStats{}, &domain.Trending{}, &domain.UserSession{}, &domain.Localization{},
&domain.LanguageAnalysis{}, &domain.TextMetadata{}, &domain.ReadabilityScore{},
&domain.TranslationStats{}, &TestEntity{},
)
repos := sql.NewRepositories(s.DB)
searchClient := search.NewClient("http://testhost", "testkey")
s.App = app.NewApplication(repos, searchClient)
var searchClient search.SearchClient = &mockSearchClient{}
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
sentimentProvider, err := linguistics.NewGoVADERSentimentProvider()
if err != nil {
s.T().Fatalf("Failed to create sentiment provider: %v", err)
}
analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider)
s.App = app.NewApplication(repos, searchClient, analyticsService)
}
// TearDownSuite cleans up the test suite
@ -121,21 +186,37 @@ func (s *IntegrationTestSuite) SetupTest() {
// CreateTestWork creates a test work with optional content
func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *domain.Work {
work := &domain.Work{
Title: title,
Language: language,
Title: title,
TranslatableModel: domain.TranslatableModel{
Language: language,
},
}
err := s.App.Repos.Work.Create(context.Background(), work)
createdWork, err := s.App.Work.Commands.CreateWork(context.Background(), work)
s.Require().NoError(err)
if content != "" {
translation := &domain.Translation{
translationInput := translation.CreateTranslationInput{
Title: title,
Content: content,
Language: language,
TranslatableID: work.ID,
TranslatableType: "Work",
TranslatableID: createdWork.ID,
TranslatableType: "works",
}
err = s.App.Repos.Translation.Create(context.Background(), translation)
_, err = s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput)
s.Require().NoError(err)
}
return work
return createdWork
}
// CreateTestTranslation creates a test translation for a work.
func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation {
translationInput := translation.CreateTranslationInput{
Title: "Test Translation",
Content: content,
Language: language,
TranslatableID: workID,
TranslatableType: "works",
}
createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput)
s.Require().NoError(err)
return createdTranslation
}

View File

@ -4,8 +4,10 @@ import (
"context"
graph "tercul/internal/adapters/graphql"
"tercul/internal/app"
"tercul/internal/app/localization"
"tercul/internal/app/work"
"tercul/internal/domain"
domain_localization "tercul/internal/domain/localization"
"github.com/stretchr/testify/suite"
)
@ -13,26 +15,24 @@ import (
// SimpleTestSuite provides a minimal test environment with just the essentials
type SimpleTestSuite struct {
suite.Suite
WorkRepo *UnifiedMockWorkRepository
WorkCommands *work.WorkCommands
WorkQueries *work.WorkQueries
MockAnalyzer *MockAnalyzer
WorkRepo *UnifiedMockWorkRepository
WorkService *work.Service
MockSearchClient *MockSearchClient
}
// MockAnalyzer is a mock implementation of the analyzer interface.
type MockAnalyzer struct{}
// MockSearchClient is a mock implementation of the search.SearchClient interface.
type MockSearchClient struct{}
// AnalyzeWork is the mock implementation of the AnalyzeWork method.
func (m *MockAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error {
// IndexWork is the mock implementation of the IndexWork method.
func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pipeline string) error {
return nil
}
// SetupSuite sets up the test suite
func (s *SimpleTestSuite) SetupSuite() {
s.WorkRepo = NewUnifiedMockWorkRepository()
s.MockAnalyzer = &MockAnalyzer{}
s.WorkCommands = work.NewWorkCommands(s.WorkRepo, s.MockAnalyzer)
s.WorkQueries = work.NewWorkQueries(s.WorkRepo)
s.MockSearchClient = &MockSearchClient{}
s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient)
}
// SetupTest resets test data for each test
@ -40,27 +40,39 @@ func (s *SimpleTestSuite) SetupTest() {
s.WorkRepo.Reset()
}
// MockLocalizationRepository is a mock implementation of the localization repository.
type MockLocalizationRepository struct{}
func (m *MockLocalizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) {
return "Test translation", nil
}
func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) {
results := make(map[string]string)
for _, key := range keys {
results[key] = "Test translation for " + key
}
return results, nil
}
// GetAuthorBiography is a mock implementation of the GetAuthorBiography method.
func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
return "This is a mock biography.", nil
}
// GetResolver returns a minimal GraphQL resolver for testing
func (s *SimpleTestSuite) GetResolver() *graph.Resolver {
var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{}
localizationService := localization.NewService(mockLocalizationRepo)
return &graph.Resolver{
App: &app.Application{
WorkCommands: s.WorkCommands,
WorkQueries: s.WorkQueries,
Localization: &MockLocalization{},
Work: s.WorkService,
Localization: localizationService,
},
}
}
type MockLocalization struct{}
func (m *MockLocalization) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) {
return "Test content for work", nil
}
func (m *MockLocalization) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) {
return "Test biography", nil
}
// CreateTestWork creates a test work with optional content
func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain.Work {
work := &domain.Work{
@ -69,10 +81,11 @@ func (s *SimpleTestSuite) CreateTestWork(title, language string, content string)
}
// Add work to the mock repository
s.WorkRepo.AddWork(work)
createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work)
s.Require().NoError(err)
// If content is provided, we'll need to handle it differently
// since the mock repository doesn't support translations yet
// For now, just return the work
return work
return createdWork
}