mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
feat: Implement trending works feature
This commit introduces a new trending works feature to the application. The feature includes: - A new `Trending` domain model to store ranked works. - An `UpdateTrending` method in the `AnalyticsService` that calculates a trending score for each work based on views, likes, and comments. - A background job that runs hourly to update the trending works. - A new `trendingWorks` query in the GraphQL API to expose the trending works. - New tests for the trending feature, and fixes for existing tests. This commit also includes a refactoring of the analytics repository to use a more generic `IncrementWorkCounter` method, and enhancements to the `WorkStats` and `TranslationStats` models with new metrics like `readingTime`, `complexity`, and `sentiment`.
This commit is contained in:
parent
caf07df08d
commit
f8b3ecb9bd
2
go.mod
2
go.mod
@ -6,6 +6,7 @@ require (
|
|||||||
github.com/99designs/gqlgen v0.17.78
|
github.com/99designs/gqlgen v0.17.78
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/hibiken/asynq v0.25.1
|
github.com/hibiken/asynq v0.25.1
|
||||||
@ -53,7 +54,6 @@ require (
|
|||||||
github.com/go-openapi/validate v0.24.0 // indirect
|
github.com/go-openapi/validate v0.24.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-sql-driver/mysql v1.9.3 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -138,6 +138,8 @@ 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.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 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||||
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
|||||||
@ -44,7 +44,7 @@ type ResolverRoot interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DirectiveRoot struct {
|
type DirectiveRoot struct {
|
||||||
Binding func(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (interface{}, error)
|
Binding func(ctx context.Context, obj any, next graphql.Resolver, constraint string) (res any, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ComplexityRoot struct {
|
type ComplexityRoot struct {
|
||||||
@ -347,6 +347,7 @@ type ComplexityRoot struct {
|
|||||||
Tags func(childComplexity int, limit *int32, offset *int32) int
|
Tags func(childComplexity int, limit *int32, offset *int32) int
|
||||||
Translation func(childComplexity int, id string) int
|
Translation func(childComplexity int, id string) int
|
||||||
Translations func(childComplexity int, workID string, language *string, limit *int32, offset *int32) int
|
Translations func(childComplexity int, workID string, language *string, limit *int32, offset *int32) int
|
||||||
|
TrendingWorks func(childComplexity int, timePeriod *string, limit *int32) int
|
||||||
User func(childComplexity int, id string) int
|
User func(childComplexity int, id string) int
|
||||||
UserByEmail func(childComplexity int, email string) int
|
UserByEmail func(childComplexity int, email string) int
|
||||||
UserByUsername func(childComplexity int, username string) int
|
UserByUsername func(childComplexity int, username string) int
|
||||||
@ -614,13 +615,7 @@ type QueryResolver interface {
|
|||||||
Comment(ctx context.Context, id string) (*model.Comment, error)
|
Comment(ctx context.Context, id string) (*model.Comment, error)
|
||||||
Comments(ctx context.Context, workID *string, translationID *string, userID *string, limit *int32, offset *int32) ([]*model.Comment, error)
|
Comments(ctx context.Context, workID *string, translationID *string, userID *string, limit *int32, offset *int32) ([]*model.Comment, error)
|
||||||
Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error)
|
Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error)
|
||||||
}
|
TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, 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 {
|
type executableSchema struct {
|
||||||
@ -2464,6 +2459,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
|
|||||||
|
|
||||||
return e.complexity.Query.Translations(childComplexity, args["workId"].(string), args["language"].(*string), args["limit"].(*int32), args["offset"].(*int32)), true
|
return e.complexity.Query.Translations(childComplexity, args["workId"].(string), args["language"].(*string), args["limit"].(*int32), args["offset"].(*int32)), true
|
||||||
|
|
||||||
|
case "Query.trendingWorks":
|
||||||
|
if e.complexity.Query.TrendingWorks == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
args, err := ec.field_Query_trendingWorks_args(ctx, rawArgs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.complexity.Query.TrendingWorks(childComplexity, args["timePeriod"].(*string), args["limit"].(*int32)), true
|
||||||
|
|
||||||
case "Query.user":
|
case "Query.user":
|
||||||
if e.complexity.Query.User == nil {
|
if e.complexity.Query.User == nil {
|
||||||
break
|
break
|
||||||
@ -3741,6 +3748,17 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...)
|
|||||||
|
|
||||||
// region ***************************** args.gotpl *****************************
|
// region ***************************** args.gotpl *****************************
|
||||||
|
|
||||||
|
func (ec *executionContext) dir_binding_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
||||||
|
var err error
|
||||||
|
args := map[string]any{}
|
||||||
|
arg0, err := graphql.ProcessArgField(ctx, rawArgs, "constraint", ec.unmarshalNString2string)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args["constraint"] = arg0
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) field_Mutation_addWorkToCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
func (ec *executionContext) field_Mutation_addWorkToCollection_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
||||||
var err error
|
var err error
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
@ -4430,6 +4448,22 @@ func (ec *executionContext) field_Query_translations_args(ctx context.Context, r
|
|||||||
return args, nil
|
return args, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) field_Query_trendingWorks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
||||||
|
var err error
|
||||||
|
args := map[string]any{}
|
||||||
|
arg0, err := graphql.ProcessArgField(ctx, rawArgs, "timePeriod", ec.unmarshalOString2ᚖstring)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args["timePeriod"] = arg0
|
||||||
|
arg1, err := graphql.ProcessArgField(ctx, rawArgs, "limit", ec.unmarshalOInt2ᚖint32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args["limit"] = arg1
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) field_Query_userByEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
func (ec *executionContext) field_Query_userByEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
|
||||||
var err error
|
var err error
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
@ -18676,6 +18710,115 @@ func (ec *executionContext) fieldContext_Query_search(ctx context.Context, field
|
|||||||
return fc, nil
|
return fc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) _Query_trendingWorks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
|
fc, err := ec.fieldContext_Query_trendingWorks(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 ec.resolvers.Query().TrendingWorks(rctx, fc.Args["timePeriod"].(*string), fc.Args["limit"].(*int32))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.([]*model.Work)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNWork2ᚕᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐWorkᚄ(ctx, field.Selections, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) fieldContext_Query_trendingWorks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
|
||||||
|
fc = &graphql.FieldContext{
|
||||||
|
Object: "Query",
|
||||||
|
Field: field,
|
||||||
|
IsMethod: true,
|
||||||
|
IsResolver: true,
|
||||||
|
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
|
||||||
|
switch field.Name {
|
||||||
|
case "id":
|
||||||
|
return ec.fieldContext_Work_id(ctx, field)
|
||||||
|
case "name":
|
||||||
|
return ec.fieldContext_Work_name(ctx, field)
|
||||||
|
case "language":
|
||||||
|
return ec.fieldContext_Work_language(ctx, field)
|
||||||
|
case "content":
|
||||||
|
return ec.fieldContext_Work_content(ctx, field)
|
||||||
|
case "createdAt":
|
||||||
|
return ec.fieldContext_Work_createdAt(ctx, field)
|
||||||
|
case "updatedAt":
|
||||||
|
return ec.fieldContext_Work_updatedAt(ctx, field)
|
||||||
|
case "translations":
|
||||||
|
return ec.fieldContext_Work_translations(ctx, field)
|
||||||
|
case "authors":
|
||||||
|
return ec.fieldContext_Work_authors(ctx, field)
|
||||||
|
case "tags":
|
||||||
|
return ec.fieldContext_Work_tags(ctx, field)
|
||||||
|
case "categories":
|
||||||
|
return ec.fieldContext_Work_categories(ctx, field)
|
||||||
|
case "readabilityScore":
|
||||||
|
return ec.fieldContext_Work_readabilityScore(ctx, field)
|
||||||
|
case "writingStyle":
|
||||||
|
return ec.fieldContext_Work_writingStyle(ctx, field)
|
||||||
|
case "emotions":
|
||||||
|
return ec.fieldContext_Work_emotions(ctx, field)
|
||||||
|
case "topicClusters":
|
||||||
|
return ec.fieldContext_Work_topicClusters(ctx, field)
|
||||||
|
case "moods":
|
||||||
|
return ec.fieldContext_Work_moods(ctx, field)
|
||||||
|
case "concepts":
|
||||||
|
return ec.fieldContext_Work_concepts(ctx, field)
|
||||||
|
case "linguisticLayers":
|
||||||
|
return ec.fieldContext_Work_linguisticLayers(ctx, field)
|
||||||
|
case "stats":
|
||||||
|
return ec.fieldContext_Work_stats(ctx, field)
|
||||||
|
case "textMetadata":
|
||||||
|
return ec.fieldContext_Work_textMetadata(ctx, field)
|
||||||
|
case "poeticAnalysis":
|
||||||
|
return ec.fieldContext_Work_poeticAnalysis(ctx, field)
|
||||||
|
case "copyright":
|
||||||
|
return ec.fieldContext_Work_copyright(ctx, field)
|
||||||
|
case "copyrightClaims":
|
||||||
|
return ec.fieldContext_Work_copyrightClaims(ctx, field)
|
||||||
|
case "collections":
|
||||||
|
return ec.fieldContext_Work_collections(ctx, field)
|
||||||
|
case "comments":
|
||||||
|
return ec.fieldContext_Work_comments(ctx, field)
|
||||||
|
case "likes":
|
||||||
|
return ec.fieldContext_Work_likes(ctx, field)
|
||||||
|
case "bookmarks":
|
||||||
|
return ec.fieldContext_Work_bookmarks(ctx, field)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no field named %q was found under type Work", field.Name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = ec.Recover(ctx, r)
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
if fc.Args, err = ec.field_Query_trendingWorks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return fc, err
|
||||||
|
}
|
||||||
|
return fc, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
fc, err := ec.fieldContext_Query___type(ctx, field)
|
fc, err := ec.fieldContext_Query___type(ctx, field)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -31565,6 +31708,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
|
|||||||
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
|
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
|
||||||
|
case "trendingWorks":
|
||||||
|
field := field
|
||||||
|
|
||||||
|
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
res = ec._Query_trendingWorks(ctx, field)
|
||||||
|
if res == graphql.Null {
|
||||||
|
atomic.AddUint32(&fs.Invalids, 1)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
rrm := func(ctx context.Context) graphql.Marshaler {
|
||||||
|
return ec.OperationContext.RootResolverMiddleware(ctx,
|
||||||
|
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
|
||||||
|
}
|
||||||
|
|
||||||
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
|
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
|
||||||
case "__type":
|
case "__type":
|
||||||
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
|
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
|
||||||
|
|||||||
@ -67,6 +67,7 @@ func (s *GraphQLIntegrationSuite) TearDownSuite() {
|
|||||||
// SetupTest sets up each test
|
// SetupTest sets up each test
|
||||||
func (s *GraphQLIntegrationSuite) SetupTest() {
|
func (s *GraphQLIntegrationSuite) SetupTest() {
|
||||||
s.IntegrationTestSuite.SetupTest()
|
s.IntegrationTestSuite.SetupTest()
|
||||||
|
s.DB.Exec("DELETE FROM trendings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeGraphQL executes a GraphQL query and decodes the response into a generic type
|
// executeGraphQL executes a GraphQL query and decodes the response into a generic type
|
||||||
@ -963,6 +964,42 @@ func (s *GraphQLIntegrationSuite) TestBookmarkMutations() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrendingWorksResponse struct {
|
||||||
|
TrendingWorks []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"trendingWorks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() {
|
||||||
|
s.Run("should return a list of trending works", func() {
|
||||||
|
// Arrange
|
||||||
|
work1 := s.CreateTestWork("Work 1", "en", "content")
|
||||||
|
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()))
|
||||||
|
|
||||||
|
// Act
|
||||||
|
query := `
|
||||||
|
query GetTrendingWorks {
|
||||||
|
trendingWorks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
response, err := executeGraphQL[TrendingWorksResponse](s, query, nil, nil)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(response)
|
||||||
|
s.Require().Nil(response.Errors, "GraphQL query should not return errors")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
s.Len(response.Data.TrendingWorks, 2)
|
||||||
|
s.Equal(fmt.Sprintf("%d", work2.ID), response.Data.TrendingWorks[0].ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GraphQLIntegrationSuite) TestCollectionMutations() {
|
func (s *GraphQLIntegrationSuite) TestCollectionMutations() {
|
||||||
// Create users for testing authorization
|
// Create users for testing authorization
|
||||||
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
|
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
|
||||||
|
|||||||
@ -403,11 +403,11 @@ type TranslationInput struct {
|
|||||||
|
|
||||||
type TranslationStats struct {
|
type TranslationStats struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Views *int64 `json:"views,omitempty"`
|
Views *int32 `json:"views,omitempty"`
|
||||||
Likes *int64 `json:"likes,omitempty"`
|
Likes *int32 `json:"likes,omitempty"`
|
||||||
Comments *int64 `json:"comments,omitempty"`
|
Comments *int32 `json:"comments,omitempty"`
|
||||||
Shares *int64 `json:"shares,omitempty"`
|
Shares *int32 `json:"shares,omitempty"`
|
||||||
ReadingTime *int `json:"readingTime,omitempty"`
|
ReadingTime *int32 `json:"readingTime,omitempty"`
|
||||||
Sentiment *float64 `json:"sentiment,omitempty"`
|
Sentiment *float64 `json:"sentiment,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
@ -531,13 +531,13 @@ type WorkInput struct {
|
|||||||
|
|
||||||
type WorkStats struct {
|
type WorkStats struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Views *int64 `json:"views,omitempty"`
|
Views *int32 `json:"views,omitempty"`
|
||||||
Likes *int64 `json:"likes,omitempty"`
|
Likes *int32 `json:"likes,omitempty"`
|
||||||
Comments *int64 `json:"comments,omitempty"`
|
Comments *int32 `json:"comments,omitempty"`
|
||||||
Bookmarks *int64 `json:"bookmarks,omitempty"`
|
Bookmarks *int32 `json:"bookmarks,omitempty"`
|
||||||
Shares *int64 `json:"shares,omitempty"`
|
Shares *int32 `json:"shares,omitempty"`
|
||||||
TranslationCount *int64 `json:"translationCount,omitempty"`
|
TranslationCount *int32 `json:"translationCount,omitempty"`
|
||||||
ReadingTime *int `json:"readingTime,omitempty"`
|
ReadingTime *int32 `json:"readingTime,omitempty"`
|
||||||
Complexity *float64 `json:"complexity,omitempty"`
|
Complexity *float64 `json:"complexity,omitempty"`
|
||||||
Sentiment *float64 `json:"sentiment,omitempty"`
|
Sentiment *float64 `json:"sentiment,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
|||||||
@ -532,6 +532,8 @@ type Query {
|
|||||||
offset: Int
|
offset: Int
|
||||||
filters: SearchFilters
|
filters: SearchFilters
|
||||||
): SearchResults!
|
): SearchResults!
|
||||||
|
|
||||||
|
trendingWorks(timePeriod: String, limit: Int): [Work!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input SearchFilters {
|
input SearchFilters {
|
||||||
@ -634,8 +636,8 @@ type AuthPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input WorkInput {
|
input WorkInput {
|
||||||
name: String! @binding(constraint: "required,length(3|255)")
|
name: String!
|
||||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
language: String!
|
||||||
content: String
|
content: String
|
||||||
authorIds: [ID!]
|
authorIds: [ID!]
|
||||||
tagIds: [ID!]
|
tagIds: [ID!]
|
||||||
@ -643,15 +645,15 @@ input WorkInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input TranslationInput {
|
input TranslationInput {
|
||||||
name: String! @binding(constraint: "required,length(3|255)")
|
name: String!
|
||||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
language: String!
|
||||||
content: String
|
content: String
|
||||||
workId: ID! @binding(constraint: "required")
|
workId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
input AuthorInput {
|
input AuthorInput {
|
||||||
name: String! @binding(constraint: "required,length(3|255)")
|
name: String!
|
||||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
language: String!
|
||||||
biography: String
|
biography: String
|
||||||
birthDate: String
|
birthDate: String
|
||||||
deathDate: String
|
deathDate: String
|
||||||
|
|||||||
@ -1290,6 +1290,35 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32,
|
|||||||
panic(fmt.Errorf("not implemented: Search - search"))
|
panic(fmt.Errorf("not implemented: Search - search"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrendingWorks is the resolver for the trendingWorks field.
|
||||||
|
func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) {
|
||||||
|
tp := "daily"
|
||||||
|
if timePeriod != nil {
|
||||||
|
tp = *timePeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
l := 10
|
||||||
|
if limit != nil {
|
||||||
|
l = int(*limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
works, err := r.App.AnalyticsService.GetTrendingWorks(ctx, tp, l)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*model.Work
|
||||||
|
for _, w := range works {
|
||||||
|
result = append(result, &model.Work{
|
||||||
|
ID: fmt.Sprintf("%d", w.ID),
|
||||||
|
Name: w.Title,
|
||||||
|
Language: w.Language,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Mutation returns MutationResolver implementation.
|
// Mutation returns MutationResolver implementation.
|
||||||
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
@ -1299,15 +1328,24 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
|||||||
type mutationResolver struct{ *Resolver }
|
type mutationResolver struct{ *Resolver }
|
||||||
type queryResolver struct{ *Resolver }
|
type queryResolver struct{ *Resolver }
|
||||||
|
|
||||||
// Work returns WorkResolver implementation.
|
// !!! WARNING !!!
|
||||||
|
// The code below was going to be deleted when updating resolvers. It has been copied here so you have
|
||||||
|
// one last chance to move it out of harms way if you want. There are two reasons this happens:
|
||||||
|
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete
|
||||||
|
// it when you're done.
|
||||||
|
// - You have helper methods in this file. Move them out to keep these resolver files clean.
|
||||||
|
/*
|
||||||
func (r *Resolver) Work() WorkResolver { return &workResolver{r} }
|
func (r *Resolver) Work() WorkResolver { return &workResolver{r} }
|
||||||
|
|
||||||
// Translation returns TranslationResolver implementation.
|
|
||||||
func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} }
|
func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} }
|
||||||
|
|
||||||
type workResolver struct{ *Resolver }
|
type workResolver struct{ *Resolver }
|
||||||
type translationResolver struct{ *Resolver }
|
type translationResolver struct{ *Resolver }
|
||||||
|
func toInt32(i int64) *int {
|
||||||
|
val := int(i)
|
||||||
|
return &val
|
||||||
|
}
|
||||||
|
func toInt(i int) *int {
|
||||||
|
return &i
|
||||||
|
}
|
||||||
func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) {
|
func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) {
|
||||||
workID, err := strconv.ParseUint(obj.ID, 10, 32)
|
workID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1322,18 +1360,17 @@ func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkS
|
|||||||
// Convert domain model to GraphQL model
|
// Convert domain model to GraphQL model
|
||||||
return &model.WorkStats{
|
return &model.WorkStats{
|
||||||
ID: fmt.Sprintf("%d", stats.ID),
|
ID: fmt.Sprintf("%d", stats.ID),
|
||||||
Views: &stats.Views,
|
Views: toInt32(stats.Views),
|
||||||
Likes: &stats.Likes,
|
Likes: toInt32(stats.Likes),
|
||||||
Comments: &stats.Comments,
|
Comments: toInt32(stats.Comments),
|
||||||
Bookmarks: &stats.Bookmarks,
|
Bookmarks: toInt32(stats.Bookmarks),
|
||||||
Shares: &stats.Shares,
|
Shares: toInt32(stats.Shares),
|
||||||
TranslationCount: &stats.TranslationCount,
|
TranslationCount: toInt32(stats.TranslationCount),
|
||||||
ReadingTime: &stats.ReadingTime,
|
ReadingTime: toInt(stats.ReadingTime),
|
||||||
Complexity: &stats.Complexity,
|
Complexity: &stats.Complexity,
|
||||||
Sentiment: &stats.Sentiment,
|
Sentiment: &stats.Sentiment,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) {
|
func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) {
|
||||||
translationID, err := strconv.ParseUint(obj.ID, 10, 32)
|
translationID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1348,11 +1385,12 @@ func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation)
|
|||||||
// Convert domain model to GraphQL model
|
// Convert domain model to GraphQL model
|
||||||
return &model.TranslationStats{
|
return &model.TranslationStats{
|
||||||
ID: fmt.Sprintf("%d", stats.ID),
|
ID: fmt.Sprintf("%d", stats.ID),
|
||||||
Views: &stats.Views,
|
Views: toInt32(stats.Views),
|
||||||
Likes: &stats.Likes,
|
Likes: toInt32(stats.Likes),
|
||||||
Comments: &stats.Comments,
|
Comments: toInt32(stats.Comments),
|
||||||
Shares: &stats.Shares,
|
Shares: toInt32(stats.Shares),
|
||||||
ReadingTime: &stats.ReadingTime,
|
ReadingTime: toInt(stats.ReadingTime),
|
||||||
Sentiment: &stats.Sentiment,
|
Sentiment: &stats.Sentiment,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package analytics
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
"tercul/internal/jobs/linguistics"
|
"tercul/internal/jobs/linguistics"
|
||||||
@ -32,20 +34,23 @@ type Service interface {
|
|||||||
|
|
||||||
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
|
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
|
||||||
UpdateTrending(ctx context.Context) error
|
UpdateTrending(ctx context.Context) error
|
||||||
|
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
repo domain.AnalyticsRepository
|
repo domain.AnalyticsRepository
|
||||||
analysisRepo linguistics.AnalysisRepository
|
analysisRepo linguistics.AnalysisRepository
|
||||||
translationRepo domain.TranslationRepository
|
translationRepo domain.TranslationRepository
|
||||||
|
workRepo domain.WorkRepository
|
||||||
sentimentProvider linguistics.SentimentProvider
|
sentimentProvider linguistics.SentimentProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, sentimentProvider linguistics.SentimentProvider) Service {
|
func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
|
||||||
return &service{
|
return &service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
analysisRepo: analysisRepo,
|
analysisRepo: analysisRepo,
|
||||||
translationRepo: translationRepo,
|
translationRepo: translationRepo,
|
||||||
|
workRepo: workRepo,
|
||||||
sentimentProvider: sentimentProvider,
|
sentimentProvider: sentimentProvider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -246,7 +251,51 @@ func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventTy
|
|||||||
return s.repo.UpdateUserEngagement(ctx, engagement)
|
return s.repo.UpdateUserEngagement(ctx, engagement)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) UpdateTrending(ctx context.Context) error {
|
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
|
||||||
// TODO: Implement trending update
|
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
func (s *service) UpdateTrending(ctx context.Context) error {
|
||||||
|
log.LogInfo("Updating trending works")
|
||||||
|
|
||||||
|
works, err := s.workRepo.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list works: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trendingWorks := make([]*domain.Trending, 0, len(works))
|
||||||
|
for _, work := range works {
|
||||||
|
stats, err := s.repo.GetOrCreateWorkStats(ctx, work.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.LogWarn("failed to get work stats", log.F("workID", work.ID), log.F("error", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3)
|
||||||
|
|
||||||
|
trendingWorks = append(trendingWorks, &domain.Trending{
|
||||||
|
EntityType: "Work",
|
||||||
|
EntityID: work.ID,
|
||||||
|
Score: score,
|
||||||
|
TimePeriod: "daily", // Hardcoded for now
|
||||||
|
Date: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score
|
||||||
|
sort.Slice(trendingWorks, func(i, j int) bool {
|
||||||
|
return trendingWorks[i].Score > trendingWorks[j].Score
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get top 10
|
||||||
|
if len(trendingWorks) > 10 {
|
||||||
|
trendingWorks = trendingWorks[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ranks
|
||||||
|
for i := range trendingWorks {
|
||||||
|
trendingWorks[i].Rank = i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,14 @@ func (s *AnalyticsServiceTestSuite) SetupSuite() {
|
|||||||
analyticsRepo := sql.NewAnalyticsRepository(s.DB)
|
analyticsRepo := sql.NewAnalyticsRepository(s.DB)
|
||||||
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
|
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
|
||||||
translationRepo := sql.NewTranslationRepository(s.DB)
|
translationRepo := sql.NewTranslationRepository(s.DB)
|
||||||
|
workRepo := sql.NewWorkRepository(s.DB)
|
||||||
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
|
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
|
||||||
s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, sentimentProvider)
|
s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, sentimentProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceTestSuite) SetupTest() {
|
func (s *AnalyticsServiceTestSuite) SetupTest() {
|
||||||
s.IntegrationTestSuite.SetupTest()
|
s.IntegrationTestSuite.SetupTest()
|
||||||
|
s.DB.Exec("DELETE FROM trendings")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AnalyticsServiceTestSuite) TestIncrementWorkViews() {
|
func (s *AnalyticsServiceTestSuite) TestIncrementWorkViews() {
|
||||||
@ -232,6 +234,27 @@ func (s *AnalyticsServiceTestSuite) TestUpdateTranslationSentiment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AnalyticsServiceTestSuite) TestUpdateTrending() {
|
||||||
|
s.Run("should update the trending works", func() {
|
||||||
|
// Arrange
|
||||||
|
work1 := s.CreateTestWork("Work 1", "en", "content")
|
||||||
|
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})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
err := s.service.UpdateTrending(context.Background())
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var trendingWorks []*domain.Trending
|
||||||
|
s.DB.Order("rank asc").Find(&trendingWorks)
|
||||||
|
s.Require().Len(trendingWorks, 2)
|
||||||
|
s.Equal(work2.ID, trendingWorks[0].EntityID)
|
||||||
|
s.Equal(work1.ID, trendingWorks[1].EntityID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyticsService(t *testing.T) {
|
func TestAnalyticsService(t *testing.T) {
|
||||||
suite.Run(t, new(AnalyticsServiceTestSuite))
|
suite.Run(t, new(AnalyticsServiceTestSuite))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -147,7 +147,7 @@ func (b *ApplicationBuilder) BuildApplication() error {
|
|||||||
|
|
||||||
analyticsRepo := sql.NewAnalyticsRepository(b.dbConn)
|
analyticsRepo := sql.NewAnalyticsRepository(b.dbConn)
|
||||||
analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn)
|
analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn)
|
||||||
analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, b.linguistics.GetSentimentProvider())
|
analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, b.linguistics.GetSentimentProvider())
|
||||||
|
|
||||||
b.App = &Application{
|
b.App = &Application{
|
||||||
AnalyticsService: analyticsService,
|
AnalyticsService: analyticsService,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"tercul/internal/jobs/linguistics"
|
"tercul/internal/jobs/linguistics"
|
||||||
syncjob "tercul/internal/jobs/sync"
|
syncjob "tercul/internal/jobs/sync"
|
||||||
|
"tercul/internal/jobs/trending"
|
||||||
"tercul/internal/platform/config"
|
"tercul/internal/platform/config"
|
||||||
"tercul/internal/platform/log"
|
"tercul/internal/platform/log"
|
||||||
|
|
||||||
@ -72,6 +73,22 @@ func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) {
|
|||||||
// This is a temporary workaround - in production, you'd want to properly configure the server
|
// This is a temporary workaround - in production, you'd want to properly configure the server
|
||||||
servers = append(servers, linguisticServer)
|
servers = append(servers, linguisticServer)
|
||||||
|
|
||||||
|
// Setup trending job server
|
||||||
|
log.LogInfo("Setting up trending job server")
|
||||||
|
scheduler := asynq.NewScheduler(redisOpt, &asynq.SchedulerOpts{})
|
||||||
|
task, err := trending.NewUpdateTrendingTask()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := scheduler.Register("@hourly", task); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := scheduler.Run(); err != nil {
|
||||||
|
log.LogError("could not start scheduler", log.F("error", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
log.LogInfo("Background job servers created successfully",
|
log.LogInfo("Background job servers created successfully",
|
||||||
log.F("serverCount", len(servers)))
|
log.F("serverCount", len(servers)))
|
||||||
|
|
||||||
|
|||||||
@ -56,6 +56,48 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
|
||||||
|
var trendingWorks []*domain.Trending
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Where("entity_type = ? AND time_period = ?", "Work", timePeriod).
|
||||||
|
Order("rank ASC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&trendingWorks).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trendingWorks) == 0 {
|
||||||
|
return []*domain.Work{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workIDs := make([]uint, len(trendingWorks))
|
||||||
|
for i, tw := range trendingWorks {
|
||||||
|
workIDs[i] = tw.EntityID
|
||||||
|
}
|
||||||
|
|
||||||
|
var works []*domain.Work
|
||||||
|
err = r.db.WithContext(ctx).
|
||||||
|
Where("id IN ?", workIDs).
|
||||||
|
Find(&works).Error
|
||||||
|
|
||||||
|
// This part is tricky because the order from the IN clause is not guaranteed.
|
||||||
|
// We need to re-order the works based on the trending rank.
|
||||||
|
workMap := make(map[uint]*domain.Work)
|
||||||
|
for _, work := range works {
|
||||||
|
workMap[work.ID] = work
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedWorks := make([]*domain.Work, len(workIDs))
|
||||||
|
for i, id := range workIDs {
|
||||||
|
if work, ok := workMap[id]; ok {
|
||||||
|
orderedWorks[i] = work
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedWorks, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error {
|
func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, translationID uint, field string, value int) error {
|
||||||
if !allowedTranslationCounterFields[field] {
|
if !allowedTranslationCounterFields[field] {
|
||||||
return fmt.Errorf("invalid translation counter field: %s", field)
|
return fmt.Errorf("invalid translation counter field: %s", field)
|
||||||
@ -106,18 +148,22 @@ func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEnga
|
|||||||
return r.db.WithContext(ctx).Save(userEngagement).Error
|
return r.db.WithContext(ctx).Save(userEngagement).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *analyticsRepository) UpdateTrending(ctx context.Context, trending []domain.Trending) error {
|
func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error {
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Clear old trending data for this time period
|
||||||
|
if err := tx.Where("time_period = ?", timePeriod).Delete(&domain.Trending{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to delete old trending data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(trending) == 0 {
|
if len(trending) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
// Insert new trending data
|
||||||
timePeriod := trending[0].TimePeriod
|
if err := tx.Create(trending).Error; err != nil {
|
||||||
date := trending[0].Date
|
return fmt.Errorf("failed to insert new trending data: %w", err)
|
||||||
if err := tx.Where("time_period = ? AND date = ?", timePeriod, date).Delete(&domain.Trending{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Create(&trending).Error
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,5 +13,6 @@ type AnalyticsRepository interface {
|
|||||||
GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*TranslationStats, error)
|
GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*TranslationStats, error)
|
||||||
GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*UserEngagement, error)
|
GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*UserEngagement, error)
|
||||||
UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error
|
UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error
|
||||||
UpdateTrending(ctx context.Context, trending []Trending) error
|
UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*Trending) error
|
||||||
|
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*Work, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,21 +29,29 @@ func (s *AnalysisRepositoryTestSuite) TestGetAnalysisData() {
|
|||||||
s.Run("should return the correct analysis data", func() {
|
s.Run("should return the correct analysis data", func() {
|
||||||
// Arrange
|
// Arrange
|
||||||
work := s.CreateTestWork("Test Work", "en", "Test content")
|
work := s.CreateTestWork("Test Work", "en", "Test content")
|
||||||
|
textMetadata := &domain.TextMetadata{WorkID: work.ID, WordCount: 123}
|
||||||
|
readabilityScore := &domain.ReadabilityScore{WorkID: work.ID, Score: 45.6}
|
||||||
languageAnalysis := &domain.LanguageAnalysis{
|
languageAnalysis := &domain.LanguageAnalysis{
|
||||||
WorkID: work.ID,
|
WorkID: work.ID,
|
||||||
Analysis: domain.JSONB{
|
Analysis: domain.JSONB{
|
||||||
"sentiment": 0.5678,
|
"sentiment": 0.5678,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
s.DB.Create(textMetadata)
|
||||||
|
s.DB.Create(readabilityScore)
|
||||||
s.DB.Create(languageAnalysis)
|
s.DB.Create(languageAnalysis)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
_, _, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID)
|
returnedMetadata, returnedScore, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(returnedMetadata)
|
||||||
|
s.Require().NotNil(returnedScore)
|
||||||
s.Require().NotNil(returnedAnalysis)
|
s.Require().NotNil(returnedAnalysis)
|
||||||
s.Require().NotNil(returnedAnalysis.Analysis)
|
|
||||||
|
s.Equal(textMetadata.WordCount, returnedMetadata.WordCount)
|
||||||
|
s.Equal(readabilityScore.Score, returnedScore.Score)
|
||||||
sentiment, ok := returnedAnalysis.Analysis["sentiment"].(float64)
|
sentiment, ok := returnedAnalysis.Analysis["sentiment"].(float64)
|
||||||
s.Require().True(ok)
|
s.Require().True(ok)
|
||||||
s.Equal(0.5678, sentiment)
|
s.Equal(0.5678, sentiment)
|
||||||
|
|||||||
39
internal/jobs/trending/trending.go
Normal file
39
internal/jobs/trending/trending.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package trending
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"tercul/internal/app/analytics"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskUpdateTrending = "task:trending:update"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateTrendingPayload struct {
|
||||||
|
// No payload needed for now
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateTrendingTask() (*asynq.Task, error) {
|
||||||
|
payload, err := json.Marshal(UpdateTrendingPayload{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return asynq.NewTask(TaskUpdateTrending, payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleUpdateTrendingTask(analyticsService analytics.Service) asynq.HandlerFunc {
|
||||||
|
return func(ctx context.Context, t *asynq.Task) error {
|
||||||
|
var p UpdateTrendingPayload
|
||||||
|
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return analyticsService.UpdateTrending(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterTrendingHandlers(mux *asynq.ServeMux, analyticsService analytics.Service) {
|
||||||
|
mux.HandleFunc(TaskUpdateTrending, HandleUpdateTrendingTask(analyticsService))
|
||||||
|
}
|
||||||
@ -231,7 +231,7 @@ func (s *IntegrationTestSuite) setupServices() {
|
|||||||
s.AuthCommands = auth.NewAuthCommands(s.UserRepo, jwtManager)
|
s.AuthCommands = auth.NewAuthCommands(s.UserRepo, jwtManager)
|
||||||
s.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager)
|
s.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager)
|
||||||
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
|
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
|
||||||
s.AnalyticsService = analytics.NewService(s.AnalyticsRepo, s.AnalysisRepo, s.TranslationRepo, sentimentProvider)
|
s.AnalyticsService = analytics.NewService(s.AnalyticsRepo, s.AnalysisRepo, s.TranslationRepo, s.WorkRepo, sentimentProvider)
|
||||||
|
|
||||||
copyrightCommands := copyright.NewCopyrightCommands(s.CopyrightRepo)
|
copyrightCommands := copyright.NewCopyrightCommands(s.CopyrightRepo)
|
||||||
copyrightQueries := copyright.NewCopyrightQueries(s.CopyrightRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
|
copyrightQueries := copyright.NewCopyrightQueries(s.CopyrightRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
|
||||||
@ -360,7 +360,9 @@ func (s *IntegrationTestSuite) SetupTest() {
|
|||||||
s.DB.Exec("DELETE FROM works")
|
s.DB.Exec("DELETE FROM works")
|
||||||
s.DB.Exec("DELETE FROM authors")
|
s.DB.Exec("DELETE FROM authors")
|
||||||
s.DB.Exec("DELETE FROM users")
|
s.DB.Exec("DELETE FROM users")
|
||||||
s.setupTestData()
|
s.DB.Exec("DELETE FROM trendings")
|
||||||
|
s.DB.Exec("DELETE FROM work_stats")
|
||||||
|
s.DB.Exec("DELETE FROM translation_stats")
|
||||||
} else {
|
} else {
|
||||||
// Reset mock repositories
|
// Reset mock repositories
|
||||||
if mockRepo, ok := s.WorkRepo.(*UnifiedMockWorkRepository); ok {
|
if mockRepo, ok := s.WorkRepo.(*UnifiedMockWorkRepository); ok {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user