diff --git a/cmd/api/server.go b/cmd/api/server.go index 26cad73..9da31ce 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -10,7 +10,9 @@ import ( // NewServer creates a new GraphQL server with the given resolver func NewServer(resolver *graphql.Resolver) http.Handler { - srv := handler.NewDefaultServer(graphql.NewExecutableSchema(graphql.Config{Resolvers: resolver})) + c := graphql.Config{Resolvers: resolver} + c.Directives.Binding = graphql.Binding + srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) mux := http.NewServeMux() @@ -21,7 +23,9 @@ func NewServer(resolver *graphql.Resolver) http.Handler { // NewServerWithAuth creates a new GraphQL server with authentication middleware func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { - srv := handler.NewDefaultServer(graphql.NewExecutableSchema(graphql.Config{Resolvers: resolver})) + c := graphql.Config{Resolvers: resolver} + c.Directives.Binding = graphql.Binding + srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) // Apply authentication middleware to GraphQL endpoint authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) diff --git a/go.mod b/go.mod index ca27b82..64f1e2c 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-windows v1.0.2 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-openapi/analysis v0.23.0 // indirect @@ -50,6 +51,9 @@ require ( github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -67,6 +71,7 @@ require ( github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect diff --git a/go.sum b/go.sum index dc1631c..2a958d7 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= @@ -136,6 +138,12 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -261,6 +269,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= diff --git a/internal/adapters/graphql/binding.go b/internal/adapters/graphql/binding.go new file mode 100644 index 0000000..5249e6a --- /dev/null +++ b/internal/adapters/graphql/binding.go @@ -0,0 +1,24 @@ +package graphql + +import ( + "context" + "fmt" + + "github.com/99designs/gqlgen/graphql" + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +func Binding(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (interface{}, error) { + val, err := next(ctx) + if err != nil { + return nil, err + } + + if err := validate.Var(val, constraint); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + return val, nil +} diff --git a/internal/adapters/graphql/generated.go b/internal/adapters/graphql/generated.go index c887570..56011fb 100644 --- a/internal/adapters/graphql/generated.go +++ b/internal/adapters/graphql/generated.go @@ -44,6 +44,7 @@ type ResolverRoot interface { } type DirectiveRoot struct { + Binding func(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (interface{}, error) } type ComplexityRoot struct { @@ -425,8 +426,13 @@ type ComplexityRoot struct { } TranslationStats struct { + Comments func(childComplexity int) int CreatedAt func(childComplexity int) int ID func(childComplexity int) int + Likes func(childComplexity int) int + ReadingTime func(childComplexity int) int + Sentiment func(childComplexity int) int + Shares func(childComplexity int) int Translation func(childComplexity int) int UpdatedAt func(childComplexity int) int Views func(childComplexity int) int @@ -522,11 +528,19 @@ type ComplexityRoot struct { } WorkStats struct { - CreatedAt func(childComplexity int) int - ID func(childComplexity int) int - UpdatedAt func(childComplexity int) int - Views func(childComplexity int) int - Work func(childComplexity int) int + Bookmarks func(childComplexity int) int + Comments func(childComplexity int) int + Complexity func(childComplexity int) int + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + Likes func(childComplexity int) int + ReadingTime func(childComplexity int) int + Sentiment func(childComplexity int) int + Shares func(childComplexity int) int + TranslationCount func(childComplexity int) int + UpdatedAt func(childComplexity int) int + Views func(childComplexity int) int + Work func(childComplexity int) int } WritingStyle struct { @@ -602,6 +616,13 @@ type QueryResolver interface { Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error) } +type WorkResolver interface { + Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) +} +type TranslationResolver interface { + Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) +} + type executableSchema struct { schema *ast.Schema resolvers ResolverRoot @@ -2863,6 +2884,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Translation.WorkID(childComplexity), true + case "TranslationStats.comments": + if e.complexity.TranslationStats.Comments == nil { + break + } + + return e.complexity.TranslationStats.Comments(childComplexity), true + case "TranslationStats.createdAt": if e.complexity.TranslationStats.CreatedAt == nil { break @@ -2877,6 +2905,34 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.TranslationStats.ID(childComplexity), true + case "TranslationStats.likes": + if e.complexity.TranslationStats.Likes == nil { + break + } + + return e.complexity.TranslationStats.Likes(childComplexity), true + + case "TranslationStats.readingTime": + if e.complexity.TranslationStats.ReadingTime == nil { + break + } + + return e.complexity.TranslationStats.ReadingTime(childComplexity), true + + case "TranslationStats.sentiment": + if e.complexity.TranslationStats.Sentiment == nil { + break + } + + return e.complexity.TranslationStats.Sentiment(childComplexity), true + + case "TranslationStats.shares": + if e.complexity.TranslationStats.Shares == nil { + break + } + + return e.complexity.TranslationStats.Shares(childComplexity), true + case "TranslationStats.translation": if e.complexity.TranslationStats.Translation == nil { break @@ -3416,6 +3472,27 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Work.WritingStyle(childComplexity), true + case "WorkStats.bookmarks": + if e.complexity.WorkStats.Bookmarks == nil { + break + } + + return e.complexity.WorkStats.Bookmarks(childComplexity), true + + case "WorkStats.comments": + if e.complexity.WorkStats.Comments == nil { + break + } + + return e.complexity.WorkStats.Comments(childComplexity), true + + case "WorkStats.complexity": + if e.complexity.WorkStats.Complexity == nil { + break + } + + return e.complexity.WorkStats.Complexity(childComplexity), true + case "WorkStats.createdAt": if e.complexity.WorkStats.CreatedAt == nil { break @@ -3430,6 +3507,41 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.WorkStats.ID(childComplexity), true + case "WorkStats.likes": + if e.complexity.WorkStats.Likes == nil { + break + } + + return e.complexity.WorkStats.Likes(childComplexity), true + + case "WorkStats.readingTime": + if e.complexity.WorkStats.ReadingTime == nil { + break + } + + return e.complexity.WorkStats.ReadingTime(childComplexity), true + + case "WorkStats.sentiment": + if e.complexity.WorkStats.Sentiment == nil { + break + } + + return e.complexity.WorkStats.Sentiment(childComplexity), true + + case "WorkStats.shares": + if e.complexity.WorkStats.Shares == nil { + break + } + + return e.complexity.WorkStats.Shares(childComplexity), true + + case "WorkStats.translationCount": + if e.complexity.WorkStats.TranslationCount == nil { + break + } + + return e.complexity.WorkStats.TranslationCount(childComplexity), true + case "WorkStats.updatedAt": if e.complexity.WorkStats.UpdatedAt == nil { break @@ -21139,6 +21251,16 @@ func (ec *executionContext) fieldContext_Translation_stats(_ context.Context, fi return ec.fieldContext_TranslationStats_id(ctx, field) case "views": return ec.fieldContext_TranslationStats_views(ctx, field) + case "likes": + return ec.fieldContext_TranslationStats_likes(ctx, field) + case "comments": + return ec.fieldContext_TranslationStats_comments(ctx, field) + case "shares": + return ec.fieldContext_TranslationStats_shares(ctx, field) + case "readingTime": + return ec.fieldContext_TranslationStats_readingTime(ctx, field) + case "sentiment": + return ec.fieldContext_TranslationStats_sentiment(ctx, field) case "createdAt": return ec.fieldContext_TranslationStats_createdAt(ctx, field) case "updatedAt": @@ -21465,14 +21587,11 @@ func (ec *executionContext) _TranslationStats_views(ctx context.Context, field g return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } - res := resTmp.(int32) + res := resTmp.(*int32) fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_TranslationStats_views(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -21488,6 +21607,211 @@ func (ec *executionContext) fieldContext_TranslationStats_views(_ context.Contex return fc, nil } +func (ec *executionContext) _TranslationStats_likes(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_likes(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Likes, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_likes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_comments(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_comments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Comments, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_comments(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_shares(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_shares(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Shares, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_shares(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_readingTime(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_readingTime(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ReadingTime, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_readingTime(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TranslationStats_sentiment(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TranslationStats_sentiment(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Sentiment, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TranslationStats_sentiment(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TranslationStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _TranslationStats_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.TranslationStats) (ret graphql.Marshaler) { fc, err := ec.fieldContext_TranslationStats_createdAt(ctx, field) if err != nil { @@ -24976,6 +25300,22 @@ func (ec *executionContext) fieldContext_Work_stats(_ context.Context, field gra return ec.fieldContext_WorkStats_id(ctx, field) case "views": return ec.fieldContext_WorkStats_views(ctx, field) + case "likes": + return ec.fieldContext_WorkStats_likes(ctx, field) + case "comments": + return ec.fieldContext_WorkStats_comments(ctx, field) + case "bookmarks": + return ec.fieldContext_WorkStats_bookmarks(ctx, field) + case "shares": + return ec.fieldContext_WorkStats_shares(ctx, field) + case "translationCount": + return ec.fieldContext_WorkStats_translationCount(ctx, field) + case "readingTime": + return ec.fieldContext_WorkStats_readingTime(ctx, field) + case "complexity": + return ec.fieldContext_WorkStats_complexity(ctx, field) + case "sentiment": + return ec.fieldContext_WorkStats_sentiment(ctx, field) case "createdAt": return ec.fieldContext_WorkStats_createdAt(ctx, field) case "updatedAt": @@ -25526,14 +25866,11 @@ func (ec *executionContext) _WorkStats_views(ctx context.Context, field graphql. return graphql.Null } if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } return graphql.Null } - res := resTmp.(int32) + res := resTmp.(*int32) fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_WorkStats_views(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -25549,6 +25886,334 @@ func (ec *executionContext) fieldContext_WorkStats_views(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _WorkStats_likes(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_likes(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Likes, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_likes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_comments(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_comments(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Comments, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_comments(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_bookmarks(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_bookmarks(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Bookmarks, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_bookmarks(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_shares(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_shares(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Shares, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_shares(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_translationCount(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_translationCount(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.TranslationCount, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_translationCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_readingTime(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_readingTime(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ReadingTime, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int32) + fc.Result = res + return ec.marshalOInt2ᚖint32(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_readingTime(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_complexity(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_complexity(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Complexity, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_complexity(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _WorkStats_sentiment(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_WorkStats_sentiment(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Sentiment, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*float64) + fc.Result = res + return ec.marshalOFloat2ᚖfloat64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_WorkStats_sentiment(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WorkStats", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _WorkStats_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.WorkStats) (ret graphql.Marshaler) { fc, err := ec.fieldContext_WorkStats_createdAt(ctx, field) if err != nil { @@ -31389,9 +32054,16 @@ func (ec *executionContext) _TranslationStats(ctx context.Context, sel ast.Selec } case "views": out.Values[i] = ec._TranslationStats_views(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } + case "likes": + out.Values[i] = ec._TranslationStats_likes(ctx, field, obj) + case "comments": + out.Values[i] = ec._TranslationStats_comments(ctx, field, obj) + case "shares": + out.Values[i] = ec._TranslationStats_shares(ctx, field, obj) + case "readingTime": + out.Values[i] = ec._TranslationStats_readingTime(ctx, field, obj) + case "sentiment": + out.Values[i] = ec._TranslationStats_sentiment(ctx, field, obj) case "createdAt": out.Values[i] = ec._TranslationStats_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -31847,9 +32519,22 @@ func (ec *executionContext) _WorkStats(ctx context.Context, sel ast.SelectionSet } case "views": out.Values[i] = ec._WorkStats_views(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } + case "likes": + out.Values[i] = ec._WorkStats_likes(ctx, field, obj) + case "comments": + out.Values[i] = ec._WorkStats_comments(ctx, field, obj) + case "bookmarks": + out.Values[i] = ec._WorkStats_bookmarks(ctx, field, obj) + case "shares": + out.Values[i] = ec._WorkStats_shares(ctx, field, obj) + case "translationCount": + out.Values[i] = ec._WorkStats_translationCount(ctx, field, obj) + case "readingTime": + out.Values[i] = ec._WorkStats_readingTime(ctx, field, obj) + case "complexity": + out.Values[i] = ec._WorkStats_complexity(ctx, field, obj) + case "sentiment": + out.Values[i] = ec._WorkStats_sentiment(ctx, field, obj) case "createdAt": out.Values[i] = ec._WorkStats_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -33908,6 +34593,23 @@ func (ec *executionContext) marshalOEmotion2ᚕᚖterculᚋinternalᚋadapters return ret } +func (ec *executionContext) unmarshalOFloat2ᚖfloat64(ctx context.Context, v any) (*float64, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalFloatContext(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOFloat2ᚖfloat64(ctx context.Context, sel ast.SelectionSet, v *float64) graphql.Marshaler { + if v == nil { + return graphql.Null + } + _ = sel + res := graphql.MarshalFloatContext(*v) + return graphql.WrapContextMarshaler(ctx, res) +} + func (ec *executionContext) unmarshalOID2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { if v == nil { return nil, nil diff --git a/internal/adapters/graphql/model/models_gen.go b/internal/adapters/graphql/model/models_gen.go index 46a08c0..3a57e23 100644 --- a/internal/adapters/graphql/model/models_gen.go +++ b/internal/adapters/graphql/model/models_gen.go @@ -45,8 +45,8 @@ type Author struct { } type AuthorInput struct { - Name string `json:"name" valid:"required,length(3|255)"` - Language string `json:"language" valid:"required,length(2|2)"` + Name string `json:"name"` + Language string `json:"language"` Biography *string `json:"biography,omitempty"` BirthDate *string `json:"birthDate,omitempty"` DeathDate *string `json:"deathDate,omitempty"` @@ -87,7 +87,7 @@ type Bookmark struct { type BookmarkInput struct { Name *string `json:"name,omitempty"` - WorkID string `json:"workId" valid:"required"` + WorkID string `json:"workId"` } type Category struct { @@ -121,7 +121,7 @@ type Collection struct { } type CollectionInput struct { - Name string `json:"name" valid:"required,length(3|255)"` + Name string `json:"name"` Description *string `json:"description,omitempty"` WorkIds []string `json:"workIds,omitempty"` } @@ -149,7 +149,7 @@ type Comment struct { } type CommentInput struct { - Text string `json:"text" valid:"required,length(1|4096)"` + Text string `json:"text"` WorkID *string `json:"workId,omitempty"` TranslationID *string `json:"translationId,omitempty"` LineNumber *int32 `json:"lineNumber,omitempty"` @@ -269,8 +269,8 @@ type LinguisticLayer struct { } type LoginInput struct { - Email string `json:"email" valid:"required,email"` - Password string `json:"password" valid:"required,length(6|255)"` + Email string `json:"email"` + Password string `json:"password"` } type Mood struct { @@ -318,11 +318,11 @@ type ReadabilityScore struct { } type RegisterInput struct { - Username string `json:"username" valid:"required,alphanum,length(3|50)"` - Email string `json:"email" valid:"required,email"` - Password string `json:"password" valid:"required,length(6|255)"` - FirstName string `json:"firstName" valid:"required,alpha,length(2|50)"` - LastName string `json:"lastName" valid:"required,alpha,length(2|50)"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` } type SearchFilters struct { @@ -395,15 +395,20 @@ type Translation struct { } type TranslationInput struct { - Name string `json:"name" valid:"required,length(3|255)"` - Language string `json:"language" valid:"required,length(2|2)"` + Name string `json:"name"` + Language string `json:"language"` Content *string `json:"content,omitempty"` - WorkID string `json:"workId" valid:"required,uuid"` + WorkID string `json:"workId"` } type TranslationStats struct { ID string `json:"id"` - Views int32 `json:"views"` + Views *int64 `json:"views,omitempty"` + Likes *int64 `json:"likes,omitempty"` + Comments *int64 `json:"comments,omitempty"` + Shares *int64 `json:"shares,omitempty"` + ReadingTime *int `json:"readingTime,omitempty"` + Sentiment *float64 `json:"sentiment,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Translation *Translation `json:"translation"` @@ -516,8 +521,8 @@ type Work struct { } type WorkInput struct { - Name string `json:"name" valid:"required,length(3|255)"` - Language string `json:"language" valid:"required,length(2|2)"` + Name string `json:"name"` + Language string `json:"language"` Content *string `json:"content,omitempty"` AuthorIds []string `json:"authorIds,omitempty"` TagIds []string `json:"tagIds,omitempty"` @@ -525,11 +530,19 @@ type WorkInput struct { } type WorkStats struct { - ID string `json:"id"` - Views int32 `json:"views"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Work *Work `json:"work"` + ID string `json:"id"` + Views *int64 `json:"views,omitempty"` + Likes *int64 `json:"likes,omitempty"` + Comments *int64 `json:"comments,omitempty"` + Bookmarks *int64 `json:"bookmarks,omitempty"` + Shares *int64 `json:"shares,omitempty"` + TranslationCount *int64 `json:"translationCount,omitempty"` + ReadingTime *int `json:"readingTime,omitempty"` + Complexity *float64 `json:"complexity,omitempty"` + Sentiment *float64 `json:"sentiment,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Work *Work `json:"work"` } type WritingStyle struct { diff --git a/internal/adapters/graphql/schema.graphqls b/internal/adapters/graphql/schema.graphqls index 1aceecc..d843bc7 100644 --- a/internal/adapters/graphql/schema.graphqls +++ b/internal/adapters/graphql/schema.graphqls @@ -289,12 +289,15 @@ type LinguisticLayer { type WorkStats { id: ID! - views: Int! - likes: Int! - comments: Int! - bookmarks: Int! - shares: Int! - translationCount: Int! + views: Int + likes: Int + comments: Int + bookmarks: Int + shares: Int + translationCount: Int + readingTime: Int + complexity: Float + sentiment: Float createdAt: String! updatedAt: String! work: Work! @@ -302,10 +305,12 @@ type WorkStats { type TranslationStats { id: ID! - views: Int! - likes: Int! - comments: Int! - shares: Int! + views: Int + likes: Int + comments: Int + shares: Int + readingTime: Int + sentiment: Float createdAt: String! updatedAt: String! translation: Translation! @@ -448,6 +453,8 @@ type Edge { scalar JSON +directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION + # Queries type Query { # Work queries @@ -627,8 +634,8 @@ type AuthPayload { } input WorkInput { - name: String! - language: String! + name: String! @binding(constraint: "required,length(3|255)") + language: String! @binding(constraint: "required,alpha,length(2|2)") content: String authorIds: [ID!] tagIds: [ID!] @@ -636,15 +643,15 @@ input WorkInput { } input TranslationInput { - name: String! - language: String! + name: String! @binding(constraint: "required,length(3|255)") + language: String! @binding(constraint: "required,alpha,length(2|2)") content: String - workId: ID! + workId: ID! @binding(constraint: "required") } input AuthorInput { - name: String! - language: String! + name: String! @binding(constraint: "required,length(3|255)") + language: String! @binding(constraint: "required,alpha,length(2|2)") biography: String birthDate: String deathDate: String diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index a14e936..a010d99 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -13,17 +13,10 @@ import ( "tercul/internal/app/auth" "tercul/internal/domain" platform_auth "tercul/internal/platform/auth" - - "github.com/asaskevich/govalidator" ) // Register is the resolver for the register field. func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInput) (*model.AuthPayload, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Convert GraphQL input to service input registerInput := auth.RegisterInput{ Username: input.Username, @@ -58,11 +51,6 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp // Login is the resolver for the login field. func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Convert GraphQL input to service input loginInput := auth.LoginInput{ Email: input.Email, @@ -94,11 +82,9 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (* // CreateWork is the resolver for the createWork field. func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateWorkInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - // Create domain model work := &domain.Work{ Title: input.Name, @@ -148,11 +134,9 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput // UpdateWork is the resolver for the updateWork field. func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateWorkInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - workID, err := strconv.ParseUint(id, 10, 32) if err != nil { return nil, fmt.Errorf("invalid work ID: %v", err) @@ -199,11 +183,9 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err // CreateTranslation is the resolver for the createTranslation field. func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateTranslationInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid work ID: %v", err) @@ -238,11 +220,9 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateTranslationInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - translationID, err := strconv.ParseUint(id, 10, 32) if err != nil { return nil, fmt.Errorf("invalid translation ID: %v", err) @@ -298,11 +278,9 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo // CreateAuthor is the resolver for the createAuthor field. func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateAuthorInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - // Create domain model author := &domain.Author{ Name: input.Name, @@ -327,11 +305,9 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI // UpdateAuthor is the resolver for the updateAuthor field. func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) + if err := validateAuthorInput(input); err != nil { + return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - authorID, err := strconv.ParseUint(id, 10, 32) if err != nil { return nil, fmt.Errorf("invalid author ID: %v", err) @@ -387,11 +363,6 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { @@ -426,11 +397,6 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { @@ -623,11 +589,6 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // 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") @@ -695,11 +656,6 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { @@ -887,11 +843,6 @@ 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) { - // Validate input - if _, err := govalidator.ValidateStruct(input); err != nil { - return nil, fmt.Errorf("invalid input: %w", err) - } - // Get user ID from context userID, ok := platform_auth.GetUserIDFromContext(ctx) if !ok { @@ -1339,7 +1290,24 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32, panic(fmt.Errorf("not implemented: Search - search")) } -// Stats is the resolver for the stats field. +// Mutation returns MutationResolver implementation. +func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } + +// Query returns QueryResolver implementation. +func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } + +type mutationResolver struct{ *Resolver } +type queryResolver struct{ *Resolver } + +// Work returns WorkResolver implementation. +func (r *Resolver) Work() WorkResolver { return &workResolver{r} } + +// Translation returns TranslationResolver implementation. +func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } + +type workResolver struct{ *Resolver } +type translationResolver struct{ *Resolver } + func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) { workID, err := strconv.ParseUint(obj.ID, 10, 32) if err != nil { @@ -1351,18 +1319,21 @@ func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkS return nil, err } + // Convert domain model to GraphQL model return &model.WorkStats{ ID: fmt.Sprintf("%d", stats.ID), - Views: int(stats.Views), - Likes: int(stats.Likes), - Comments: int(stats.Comments), - Bookmarks: int(stats.Bookmarks), - Shares: int(stats.Shares), - TranslationCount: int(stats.TranslationCount), + Views: &stats.Views, + Likes: &stats.Likes, + Comments: &stats.Comments, + Bookmarks: &stats.Bookmarks, + Shares: &stats.Shares, + TranslationCount: &stats.TranslationCount, + ReadingTime: &stats.ReadingTime, + Complexity: &stats.Complexity, + Sentiment: &stats.Sentiment, }, nil } -// Stats is the resolver for the stats field. func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) { translationID, err := strconv.ParseUint(obj.ID, 10, 32) if err != nil { @@ -1374,28 +1345,14 @@ func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) return nil, err } + // Convert domain model to GraphQL model return &model.TranslationStats{ - ID: fmt.Sprintf("%d", stats.ID), - Views: int(stats.Views), - Likes: int(stats.Likes), - Comments: int(stats.Comments), - Shares: int(stats.Shares), + ID: fmt.Sprintf("%d", stats.ID), + Views: &stats.Views, + Likes: &stats.Likes, + Comments: &stats.Comments, + Shares: &stats.Shares, + ReadingTime: &stats.ReadingTime, + Sentiment: &stats.Sentiment, }, nil } - -// Mutation returns MutationResolver implementation. -func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } - -// Query returns QueryResolver implementation. -func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } - -// Work returns WorkResolver implementation. -func (r *Resolver) Work() WorkResolver { return &workResolver{r} } - -// Translation returns TranslationResolver implementation. -func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } - -type mutationResolver struct{ *Resolver } -type queryResolver struct{ *Resolver } -type workResolver struct{ *Resolver } -type translationResolver struct{ *Resolver } diff --git a/internal/adapters/graphql/validation.go b/internal/adapters/graphql/validation.go new file mode 100644 index 0000000..c16f69c --- /dev/null +++ b/internal/adapters/graphql/validation.go @@ -0,0 +1,57 @@ +package graphql + +import ( + "errors" + "fmt" + "strings" + "tercul/internal/adapters/graphql/model" + + "github.com/asaskevich/govalidator" +) + +var ErrValidation = errors.New("validation failed") + +func validateWorkInput(input model.WorkInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + return nil +} + +func validateAuthorInput(input model.AuthorInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + return nil +} + +func validateTranslationInput(input model.TranslationInput) error { + name := strings.TrimSpace(input.Name) + if len(name) < 3 { + return fmt.Errorf("name must be at least 3 characters long") + } + if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) { + return fmt.Errorf("name can only contain letters, numbers, and spaces") + } + if len(input.Language) != 2 { + return fmt.Errorf("language must be a 2-character code") + } + if input.WorkID == "" { + return fmt.Errorf("workId is required") + } + return nil +} diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index 1b98595..865201d 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -2,7 +2,12 @@ package analytics import ( "context" + "errors" + "strings" "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/platform/log" + "time" ) type Service interface { @@ -18,54 +23,71 @@ type Service interface { IncrementTranslationShares(ctx context.Context, translationID uint) 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 + UpdateWorkComplexity(ctx context.Context, workID uint) error + UpdateWorkSentiment(ctx context.Context, workID uint) error + UpdateTranslationReadingTime(ctx context.Context, translationID uint) error + UpdateTranslationSentiment(ctx context.Context, translationID uint) error + + UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error + UpdateTrending(ctx context.Context) error } type service struct { - repo domain.AnalyticsRepository + repo domain.AnalyticsRepository + analysisRepo linguistics.AnalysisRepository + translationRepo domain.TranslationRepository + sentimentProvider linguistics.SentimentProvider } -func NewService(repo domain.AnalyticsRepository) Service { - return &service{repo: repo} +func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, sentimentProvider linguistics.SentimentProvider) Service { + return &service{ + repo: repo, + analysisRepo: analysisRepo, + translationRepo: translationRepo, + sentimentProvider: sentimentProvider, + } } func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkViews(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "views", 1) } func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkLikes(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) } func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkComments(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1) } func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkBookmarks(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1) } func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkShares(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1) } func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { - return s.repo.IncrementWorkTranslationCount(ctx, workID) + return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1) } func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error { - return s.repo.IncrementTranslationViews(ctx, translationID) + return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1) } func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error { - return s.repo.IncrementTranslationLikes(ctx, translationID) + return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) } func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error { - return s.repo.IncrementTranslationComments(ctx, translationID) + return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1) } func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error { - return s.repo.IncrementTranslationShares(ctx, translationID) + return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) } func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { @@ -75,3 +97,156 @@ func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domai func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { return s.repo.GetOrCreateTranslationStats(ctx, translationID) } + +func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + return err + } + + if textMetadata == nil { + return errors.New("text metadata not found") + } + + readingTime := 0 + if textMetadata.WordCount > 0 { + readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if readabilityScore == nil { + return errors.New("readability score not found") + } + + stats.Complexity = readabilityScore.Score + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if languageAnalysis == nil { + return errors.New("language analysis not found") + } + + sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64) + if !ok { + return errors.New("sentiment score not found in language analysis") + } + + stats.Sentiment = sentiment + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + wordCount := len(strings.Fields(translation.Content)) + readingTime := 0 + if wordCount > 0 { + readingTime = (wordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language) + if err != nil { + return err + } + + stats.Sentiment = sentiment + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + today := time.Now().UTC().Truncate(24 * time.Hour) + engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today) + if err != nil { + return err + } + + switch eventType { + case "work_read": + engagement.WorksRead++ + case "comment_made": + engagement.CommentsMade++ + case "like_given": + engagement.LikesGiven++ + case "bookmark_made": + engagement.BookmarksMade++ + case "translation_made": + engagement.TranslationsMade++ + default: + return errors.New("invalid engagement event type") + } + + return s.repo.UpdateUserEngagement(ctx, engagement) +} + +func (s *service) UpdateTrending(ctx context.Context) error { + // TODO: Implement trending update + return nil +} diff --git a/internal/app/analytics/service_test.go b/internal/app/analytics/service_test.go index 1297cd5..7f04bdc 100644 --- a/internal/app/analytics/service_test.go +++ b/internal/app/analytics/service_test.go @@ -2,9 +2,12 @@ package analytics_test import ( "context" + "strings" "testing" "tercul/internal/app/analytics" "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" "tercul/internal/testutil" "github.com/stretchr/testify/suite" @@ -18,7 +21,10 @@ type AnalyticsServiceTestSuite struct { func (s *AnalyticsServiceTestSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) analyticsRepo := sql.NewAnalyticsRepository(s.DB) - s.service = analytics.NewService(analyticsRepo) + analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB) + translationRepo := sql.NewTranslationRepository(s.DB) + sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider() + s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, sentimentProvider) } func (s *AnalyticsServiceTestSuite) SetupTest() { @@ -121,6 +127,111 @@ func (s *AnalyticsServiceTestSuite) TestIncrementWorkTranslationCount() { }) } +func (s *AnalyticsServiceTestSuite) TestUpdateWorkReadingTime() { + s.Run("should update the reading time for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + textMetadata := &domain.TextMetadata{ + WorkID: work.ID, + WordCount: 1000, + } + s.DB.Create(textMetadata) + + // Act + err := s.service.UpdateWorkReadingTime(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(5, stats.ReadingTime) // 1000 words / 200 wpm = 5 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationReadingTime() { + s.Run("should update the reading time for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "es", strings.Repeat("Contenido de prueba con quinientas palabras. ", 100)) + + // Act + err := s.service.UpdateTranslationReadingTime(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.Equal(3, stats.ReadingTime) // 500 words / 200 wpm = 2.5 -> 3 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkComplexity() { + s.Run("should update the complexity for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + readabilityScore := &domain.ReadabilityScore{ + WorkID: work.ID, + Score: 12.34, + } + s.DB.Create(readabilityScore) + + // Act + err := s.service.UpdateWorkComplexity(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(12.34, stats.Complexity) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkSentiment() { + s.Run("should update the sentiment for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + languageAnalysis := &domain.LanguageAnalysis{ + WorkID: work.ID, + Analysis: domain.JSONB{ + "sentiment": 0.5678, + }, + } + s.DB.Create(languageAnalysis) + + // Act + err := s.service.UpdateWorkSentiment(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(0.5678, stats.Sentiment) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationSentiment() { + s.Run("should update the sentiment for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "en", "This is a wonderfully positive and uplifting sentence.") + + // Act + err := s.service.UpdateTranslationSentiment(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.True(stats.Sentiment > 0.5) + }) +} + func TestAnalyticsService(t *testing.T) { suite.Run(t, new(AnalyticsServiceTestSuite)) } diff --git a/internal/app/application_builder.go b/internal/app/application_builder.go index b5111b5..2b13ff3 100644 --- a/internal/app/application_builder.go +++ b/internal/app/application_builder.go @@ -95,7 +95,18 @@ func (b *ApplicationBuilder) BuildBackgroundJobs() error { // BuildLinguistics initializes the linguistics components func (b *ApplicationBuilder) BuildLinguistics() error { log.LogInfo("Initializing linguistic analyzer") - b.linguistics = linguistics.NewLinguisticsFactory(b.dbConn, b.redisCache, 4, true) + + // Create sentiment provider + var sentimentProvider linguistics.SentimentProvider + sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() + if err != nil { + log.LogWarn("Failed to initialize GoVADER sentiment provider, using rule-based fallback", log.F("error", err)) + sentimentProvider = &linguistics.RuleBasedSentimentProvider{} + } + + // Create linguistics factory and pass in the sentiment provider + b.linguistics = linguistics.NewLinguisticsFactory(b.dbConn, b.redisCache, 4, true, sentimentProvider) + log.LogInfo("Linguistics components initialized successfully") return nil } @@ -135,7 +146,8 @@ func (b *ApplicationBuilder) BuildApplication() error { searchService := app_search.NewIndexService(localizationService, b.weaviateWrapper) analyticsRepo := sql.NewAnalyticsRepository(b.dbConn) - analyticsService := analytics.NewService(analyticsRepo) + analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn) + analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, b.linguistics.GetSentimentProvider()) b.App = &Application{ AnalyticsService: analyticsService, diff --git a/internal/data/sql/analytics_repository.go b/internal/data/sql/analytics_repository.go index 541a8e2..90f4db8 100644 --- a/internal/data/sql/analytics_repository.go +++ b/internal/data/sql/analytics_repository.go @@ -2,7 +2,9 @@ package sql import ( "context" + "fmt" "tercul/internal/domain" + "time" "gorm.io/gorm" ) @@ -15,84 +17,71 @@ func NewAnalyticsRepository(db *gorm.DB) domain.AnalyticsRepository { return &analyticsRepository{db: db} } -func (r *analyticsRepository) IncrementWorkViews(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("views", gorm.Expr("views + 1")).Error +var allowedWorkCounterFields = map[string]bool{ + "views": true, + "likes": true, + "comments": true, + "bookmarks": true, + "shares": true, + "translation_count": true, } -func (r *analyticsRepository) IncrementWorkLikes(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("likes", gorm.Expr("likes + 1")).Error +var allowedTranslationCounterFields = map[string]bool{ + "views": true, + "likes": true, + "comments": true, + "shares": true, } -func (r *analyticsRepository) IncrementWorkComments(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err +func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error { + if !allowedWorkCounterFields[field] { + return fmt.Errorf("invalid work counter field: %s", field) } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("comments", gorm.Expr("comments + 1")).Error + + // 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(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) + if result.Error != nil { + return result.Error + } + + // 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(&domain.WorkStats{}).Create(initialData).Error + } + + return nil + }) } -func (r *analyticsRepository) IncrementWorkBookmarks(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err +func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error { + if !allowedTranslationCounterFields[field] { + return fmt.Errorf("invalid translation counter field: %s", field) } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("bookmarks", gorm.Expr("bookmarks + 1")).Error + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn(field, gorm.Expr(fmt.Sprintf("%s + ?", field), value)) + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + initialData := map[string]interface{}{"translation_id": translationID, field: value} + return tx.Model(&domain.TranslationStats{}).Create(initialData).Error + } + + return nil + }) } -func (r *analyticsRepository) IncrementWorkShares(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("shares", gorm.Expr("shares + 1")).Error +func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { + return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error } -func (r *analyticsRepository) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { - _, err := r.GetOrCreateWorkStats(ctx, workID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.WorkStats{}).Where("work_id = ?", workID).UpdateColumn("translation_count", gorm.Expr("translation_count + 1")).Error -} - -func (r *analyticsRepository) IncrementTranslationViews(ctx context.Context, translationID uint) error { - _, err := r.GetOrCreateTranslationStats(ctx, translationID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn("views", gorm.Expr("views + 1")).Error -} - -func (r *analyticsRepository) IncrementTranslationLikes(ctx context.Context, translationID uint) error { - _, err := r.GetOrCreateTranslationStats(ctx, translationID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn("likes", gorm.Expr("likes + 1")).Error -} - -func (r *analyticsRepository) IncrementTranslationComments(ctx context.Context, translationID uint) error { - _, err := r.GetOrCreateTranslationStats(ctx, translationID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn("comments", gorm.Expr("comments + 1")).Error -} - -func (r *analyticsRepository) IncrementTranslationShares(ctx context.Context, translationID uint) error { - _, err := r.GetOrCreateTranslationStats(ctx, translationID) - if err != nil { - return err - } - return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).UpdateColumn("shares", gorm.Expr("shares + 1")).Error +func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { + return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error } func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { @@ -106,3 +95,29 @@ func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, t err := r.db.WithContext(ctx).Where(domain.TranslationStats{TranslationID: translationID}).FirstOrCreate(&stats).Error return &stats, err } + +func (r *analyticsRepository) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { + var engagement domain.UserEngagement + err := r.db.WithContext(ctx).Where(domain.UserEngagement{UserID: userID, Date: date}).FirstOrCreate(&engagement).Error + return &engagement, err +} + +func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error { + return r.db.WithContext(ctx).Save(userEngagement).Error +} + +func (r *analyticsRepository) UpdateTrending(ctx context.Context, trending []domain.Trending) error { + if len(trending) == 0 { + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + timePeriod := trending[0].TimePeriod + date := trending[0].Date + if err := tx.Where("time_period = ? AND date = ?", timePeriod, date).Delete(&domain.Trending{}).Error; err != nil { + return err + } + + return tx.Create(&trending).Error + }) +} diff --git a/internal/domain/analytics.go b/internal/domain/analytics.go index ed3e288..2acc38b 100644 --- a/internal/domain/analytics.go +++ b/internal/domain/analytics.go @@ -2,17 +2,16 @@ package domain import "context" +import "time" + type AnalyticsRepository interface { - IncrementWorkViews(ctx context.Context, workID uint) error - IncrementWorkLikes(ctx context.Context, workID uint) error - IncrementWorkComments(ctx context.Context, workID uint) error - IncrementWorkBookmarks(ctx context.Context, workID uint) error - IncrementWorkShares(ctx context.Context, workID uint) error - IncrementWorkTranslationCount(ctx context.Context, workID uint) error - IncrementTranslationViews(ctx context.Context, translationID uint) error - IncrementTranslationLikes(ctx context.Context, translationID uint) error - IncrementTranslationComments(ctx context.Context, translationID uint) error - IncrementTranslationShares(ctx context.Context, translationID uint) error + 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 WorkStats) error + UpdateTranslationStats(ctx context.Context, translationID uint, stats TranslationStats) error GetOrCreateWorkStats(ctx context.Context, workID uint) (*WorkStats, error) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*TranslationStats, error) + GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*UserEngagement, error) + UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error + UpdateTrending(ctx context.Context, trending []Trending) error } diff --git a/internal/domain/entities.go b/internal/domain/entities.go index 465a465..dec2936 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -744,24 +744,52 @@ type AuditLog struct { 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"` - WorkID uint `gorm:"uniqueIndex;index"` - Work *Work `gorm:"foreignKey:WorkID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + 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;"` } type TranslationStats struct { BaseModel - Views int64 `gorm:"default:0"` - Likes int64 `gorm:"default:0"` - Comments int64 `gorm:"default:0"` - Shares int64 `gorm:"default:0"` - TranslationID uint `gorm:"uniqueIndex;index"` + Views int64 `gorm:"default:0"` + Likes int64 `gorm:"default:0"` + Comments int64 `gorm:"default:0"` + Shares int64 `gorm:"default:0"` + ReadingTime int `gorm:"default:0"` + Sentiment float64 `gorm:"type:decimal(5,2);default:0.0"` + TranslationID uint `gorm:"uniqueIndex;index"` Translation *Translation `gorm:"foreignKey:TranslationID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` } + +type UserEngagement struct { + BaseModel + UserID uint `gorm:"index;uniqueIndex:uniq_user_engagement_date"` + User *User `gorm:"foreignKey:UserID"` + Date time.Time `gorm:"type:date;uniqueIndex:uniq_user_engagement_date"` + WorksRead int `gorm:"default:0"` + CommentsMade int `gorm:"default:0"` + LikesGiven int `gorm:"default:0"` + BookmarksMade int `gorm:"default:0"` + TranslationsMade int `gorm:"default:0"` +} + +type Trending struct { + BaseModel + EntityType string `gorm:"size:50;not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + EntityID uint `gorm:"not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + Rank int `gorm:"not null;uniqueIndex:uniq_trending_rank"` + Score float64 `gorm:"type:decimal(10,2);default:0.0"` + TimePeriod string `gorm:"size:50;not null;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` + Date time.Time `gorm:"type:date;index:idx_trending_entity_period_date,uniqueIndex:uniq_trending_rank"` +} + type UserStats struct { BaseModel Activity int64 `gorm:"default:0"` diff --git a/internal/jobs/linguistics/analysis_repository.go b/internal/jobs/linguistics/analysis_repository.go index 47f7cec..0198768 100644 --- a/internal/jobs/linguistics/analysis_repository.go +++ b/internal/jobs/linguistics/analysis_repository.go @@ -153,6 +153,7 @@ func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uin if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&languageAnalysis).Error; err != nil { log.LogWarn("No language analysis found for work", log.F("workID", workID)) + return nil, nil, nil, err } return &textMetadata, &readabilityScore, &languageAnalysis, nil diff --git a/internal/jobs/linguistics/analysis_repository_test.go b/internal/jobs/linguistics/analysis_repository_test.go new file mode 100644 index 0000000..6910a03 --- /dev/null +++ b/internal/jobs/linguistics/analysis_repository_test.go @@ -0,0 +1,55 @@ +package linguistics_test + +import ( + "context" + "testing" + + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AnalysisRepositoryTestSuite struct { + testutil.IntegrationTestSuite + repo linguistics.AnalysisRepository +} + +func (s *AnalysisRepositoryTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + s.repo = linguistics.NewGORMAnalysisRepository(s.DB) +} + +func (s *AnalysisRepositoryTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() +} + +func (s *AnalysisRepositoryTestSuite) TestGetAnalysisData() { + s.Run("should return the correct analysis data", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + languageAnalysis := &domain.LanguageAnalysis{ + WorkID: work.ID, + Analysis: domain.JSONB{ + "sentiment": 0.5678, + }, + } + s.DB.Create(languageAnalysis) + + // Act + _, _, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID) + + // Assert + s.Require().NoError(err) + s.Require().NotNil(returnedAnalysis) + s.Require().NotNil(returnedAnalysis.Analysis) + sentiment, ok := returnedAnalysis.Analysis["sentiment"].(float64) + s.Require().True(ok) + s.Equal(0.5678, sentiment) + }) +} + +func TestAnalysisRepository(t *testing.T) { + suite.Run(t, new(AnalysisRepositoryTestSuite)) +} diff --git a/internal/jobs/linguistics/factory.go b/internal/jobs/linguistics/factory.go index fea6da2..69c25a1 100644 --- a/internal/jobs/linguistics/factory.go +++ b/internal/jobs/linguistics/factory.go @@ -14,6 +14,7 @@ type LinguisticsFactory struct { analysisRepo AnalysisRepository workAnalysisService WorkAnalysisService analyzer Analyzer + sentimentProvider SentimentProvider } // NewLinguisticsFactory creates a new LinguisticsFactory with all components @@ -22,20 +23,13 @@ func NewLinguisticsFactory( cache cache.Cache, concurrency int, cacheEnabled bool, + sentimentProvider SentimentProvider, ) *LinguisticsFactory { // Create text analyzer and wire providers (prefer external libs when available) textAnalyzer := NewBasicTextAnalyzer() - // Wire sentiment provider: GoVADER (configurable) - if config.Cfg.NLPUseVADER { - if sp, err := NewGoVADERSentimentProvider(); err == nil { - textAnalyzer = textAnalyzer.WithSentimentProvider(sp) - } else { - textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) - } - } else { - textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) - } + // Wire sentiment provider + textAnalyzer = textAnalyzer.WithSentimentProvider(sentimentProvider) // Wire language detector: lingua-go (configurable) if config.Cfg.NLPUseLingua { @@ -79,6 +73,7 @@ func NewLinguisticsFactory( analysisRepo: analysisRepo, workAnalysisService: workAnalysisService, analyzer: analyzer, + sentimentProvider: sentimentProvider, } } @@ -106,3 +101,8 @@ func (f *LinguisticsFactory) GetWorkAnalysisService() WorkAnalysisService { func (f *LinguisticsFactory) GetAnalyzer() Analyzer { return f.analyzer } + +// GetSentimentProvider returns the sentiment provider +func (f *LinguisticsFactory) GetSentimentProvider() SentimentProvider { + return f.sentimentProvider +} diff --git a/internal/jobs/linguistics/factory_test.go b/internal/jobs/linguistics/factory_test.go index 2496aaa..65939d9 100644 --- a/internal/jobs/linguistics/factory_test.go +++ b/internal/jobs/linguistics/factory_test.go @@ -7,7 +7,7 @@ import ( func TestFactory_WiresProviders(t *testing.T) { // We won't spin a DB/cache here; this is a smoke test of wiring methods - f := NewLinguisticsFactory(nil, nil, 2, true) + f := NewLinguisticsFactory(nil, nil, 2, true, nil) ta := f.GetTextAnalyzer().(*BasicTextAnalyzer) require.NotNil(t, ta) } diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 059b01f..5b759bc 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -18,40 +18,44 @@ import ( "tercul/internal/app" "tercul/internal/app/copyright" "tercul/internal/app/localization" + "tercul/internal/app/analytics" "tercul/internal/app/monetization" "tercul/internal/app/search" "tercul/internal/app/work" "tercul/internal/data/sql" "tercul/internal/domain" + "tercul/internal/jobs/linguistics" ) // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite - App *app.Application - DB *gorm.DB - WorkRepo domain.WorkRepository - UserRepo domain.UserRepository - AuthorRepo domain.AuthorRepository - TranslationRepo domain.TranslationRepository - CommentRepo domain.CommentRepository - LikeRepo domain.LikeRepository - BookmarkRepo domain.BookmarkRepository - CollectionRepo domain.CollectionRepository - TagRepo domain.TagRepository - CategoryRepo domain.CategoryRepository - BookRepo domain.BookRepository - MonetizationRepo domain.MonetizationRepository - PublisherRepo domain.PublisherRepository - SourceRepo domain.SourceRepository - CopyrightRepo domain.CopyrightRepository - + App *app.Application + DB *gorm.DB + WorkRepo domain.WorkRepository + UserRepo domain.UserRepository + AuthorRepo domain.AuthorRepository + TranslationRepo domain.TranslationRepository + CommentRepo domain.CommentRepository + LikeRepo domain.LikeRepository + BookmarkRepo domain.BookmarkRepository + CollectionRepo domain.CollectionRepository + TagRepo domain.TagRepository + CategoryRepo domain.CategoryRepository + BookRepo domain.BookRepository + MonetizationRepo domain.MonetizationRepository + PublisherRepo domain.PublisherRepository + SourceRepo domain.SourceRepository + CopyrightRepo domain.CopyrightRepository + AnalyticsRepo domain.AnalyticsRepository + AnalysisRepo linguistics.AnalysisRepository // Services WorkCommands *work.WorkCommands WorkQueries *work.WorkQueries Localization localization.Service AuthCommands *auth.AuthCommands AuthQueries *auth.AuthQueries + AnalyticsService analytics.Service // Test data TestWorks []*domain.Work @@ -159,6 +163,8 @@ func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { &domain.BookMonetization{}, &domain.PublisherMonetization{}, &domain.SourceMonetization{}, + &domain.WorkStats{}, + &domain.TranslationStats{}, // &domain.WorkAnalytics{}, // Commented out as it's not in models package &domain.ReadabilityScore{}, &domain.WritingStyle{}, @@ -168,8 +174,12 @@ func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { &domain.Concept{}, &domain.LinguisticLayer{}, &domain.WorkStats{}, + &domain.TranslationStats{}, + &domain.UserEngagement{}, + &domain.Trending{}, &domain.TextMetadata{}, &domain.PoeticAnalysis{}, + &domain.LanguageAnalysis{}, &domain.TranslationField{}, &TestEntity{}, // Add TestEntity for generic repository tests ); err != nil { @@ -192,6 +202,8 @@ func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { s.PublisherRepo = sql.NewPublisherRepository(db) s.SourceRepo = sql.NewSourceRepository(db) s.CopyrightRepo = sql.NewCopyrightRepository(db) + s.AnalyticsRepo = sql.NewAnalyticsRepository(db) + s.AnalysisRepo = linguistics.NewGORMAnalysisRepository(db) } // setupMockRepositories sets up mock repositories for testing @@ -218,6 +230,8 @@ func (s *IntegrationTestSuite) setupServices() { jwtManager := auth_platform.NewJWTManager() s.AuthCommands = auth.NewAuthCommands(s.UserRepo, jwtManager) s.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager) + sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider() + s.AnalyticsService = analytics.NewService(s.AnalyticsRepo, s.AnalysisRepo, s.TranslationRepo, sentimentProvider) copyrightCommands := copyright.NewCopyrightCommands(s.CopyrightRepo) copyrightQueries := copyright.NewCopyrightQueries(s.CopyrightRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo) @@ -226,6 +240,7 @@ func (s *IntegrationTestSuite) setupServices() { monetizationQueries := monetization.NewMonetizationQueries(s.MonetizationRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo) s.App = &app.Application{ + AnalyticsService: s.AnalyticsService, WorkCommands: s.WorkCommands, WorkQueries: s.WorkQueries, AuthCommands: s.AuthCommands, @@ -418,3 +433,17 @@ func (s *IntegrationTestSuite) CreateAuthenticatedUser(username, email string, r return user, token } + +// CreateTestTranslation creates a test translation for a work +func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation { + translation := &domain.Translation{ + Title: "Test Translation", + Content: content, + Language: language, + TranslatableID: workID, + TranslatableType: "Work", + } + err := s.TranslationRepo.Create(context.Background(), translation) + s.Require().NoError(err) + return translation +}