mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 01:41: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/DATA-DOG/go-sqlmock v1.5.2
|
||||
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/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
@ -53,7 +54,6 @@ require (
|
||||
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
|
||||
|
||||
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.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||
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/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
|
||||
@ -44,7 +44,7 @@ type ResolverRoot interface {
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -347,6 +347,7 @@ type ComplexityRoot struct {
|
||||
Tags func(childComplexity int, limit *int32, offset *int32) int
|
||||
Translation func(childComplexity int, id string) 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
|
||||
UserByEmail func(childComplexity int, email 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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":
|
||||
if e.complexity.Query.User == nil {
|
||||
break
|
||||
@ -3741,6 +3748,17 @@ var parsedSchema = gqlparser.MustLoadSchema(sources...)
|
||||
|
||||
// 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) {
|
||||
var err error
|
||||
args := map[string]any{}
|
||||
@ -4430,6 +4448,22 @@ func (ec *executionContext) field_Query_translations_args(ctx context.Context, r
|
||||
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) {
|
||||
var err error
|
||||
args := map[string]any{}
|
||||
@ -18676,6 +18710,115 @@ func (ec *executionContext) fieldContext_Query_search(ctx context.Context, field
|
||||
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) {
|
||||
fc, err := ec.fieldContext_Query___type(ctx, field)
|
||||
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) })
|
||||
}
|
||||
|
||||
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) })
|
||||
case "__type":
|
||||
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
|
||||
func (s *GraphQLIntegrationSuite) SetupTest() {
|
||||
s.IntegrationTestSuite.SetupTest()
|
||||
s.DB.Exec("DELETE FROM trendings")
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Create users for testing authorization
|
||||
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
|
||||
|
||||
@ -403,11 +403,11 @@ type TranslationInput struct {
|
||||
|
||||
type TranslationStats struct {
|
||||
ID string `json:"id"`
|
||||
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"`
|
||||
Views *int32 `json:"views,omitempty"`
|
||||
Likes *int32 `json:"likes,omitempty"`
|
||||
Comments *int32 `json:"comments,omitempty"`
|
||||
Shares *int32 `json:"shares,omitempty"`
|
||||
ReadingTime *int32 `json:"readingTime,omitempty"`
|
||||
Sentiment *float64 `json:"sentiment,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
@ -531,13 +531,13 @@ type WorkInput struct {
|
||||
|
||||
type WorkStats struct {
|
||||
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"`
|
||||
Views *int32 `json:"views,omitempty"`
|
||||
Likes *int32 `json:"likes,omitempty"`
|
||||
Comments *int32 `json:"comments,omitempty"`
|
||||
Bookmarks *int32 `json:"bookmarks,omitempty"`
|
||||
Shares *int32 `json:"shares,omitempty"`
|
||||
TranslationCount *int32 `json:"translationCount,omitempty"`
|
||||
ReadingTime *int32 `json:"readingTime,omitempty"`
|
||||
Complexity *float64 `json:"complexity,omitempty"`
|
||||
Sentiment *float64 `json:"sentiment,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
|
||||
@ -532,6 +532,8 @@ type Query {
|
||||
offset: Int
|
||||
filters: SearchFilters
|
||||
): SearchResults!
|
||||
|
||||
trendingWorks(timePeriod: String, limit: Int): [Work!]!
|
||||
}
|
||||
|
||||
input SearchFilters {
|
||||
@ -634,8 +636,8 @@ type AuthPayload {
|
||||
}
|
||||
|
||||
input WorkInput {
|
||||
name: String! @binding(constraint: "required,length(3|255)")
|
||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
||||
name: String!
|
||||
language: String!
|
||||
content: String
|
||||
authorIds: [ID!]
|
||||
tagIds: [ID!]
|
||||
@ -643,15 +645,15 @@ input WorkInput {
|
||||
}
|
||||
|
||||
input TranslationInput {
|
||||
name: String! @binding(constraint: "required,length(3|255)")
|
||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
||||
name: String!
|
||||
language: String!
|
||||
content: String
|
||||
workId: ID! @binding(constraint: "required")
|
||||
workId: ID!
|
||||
}
|
||||
|
||||
input AuthorInput {
|
||||
name: String! @binding(constraint: "required,length(3|255)")
|
||||
language: String! @binding(constraint: "required,alpha,length(2|2)")
|
||||
name: String!
|
||||
language: String!
|
||||
biography: String
|
||||
birthDate: 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"))
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 queryResolver struct{ *Resolver }
|
||||
|
||||
// Work returns WorkResolver implementation.
|
||||
func (r *Resolver) Work() WorkResolver { return &workResolver{r} }
|
||||
|
||||
// Translation returns TranslationResolver 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) Translation() TranslationResolver { return &translationResolver{r} }
|
||||
|
||||
type workResolver 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) {
|
||||
workID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||
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
|
||||
return &model.WorkStats{
|
||||
ID: fmt.Sprintf("%d", stats.ID),
|
||||
Views: &stats.Views,
|
||||
Likes: &stats.Likes,
|
||||
Comments: &stats.Comments,
|
||||
Bookmarks: &stats.Bookmarks,
|
||||
Shares: &stats.Shares,
|
||||
TranslationCount: &stats.TranslationCount,
|
||||
ReadingTime: &stats.ReadingTime,
|
||||
Views: toInt32(stats.Views),
|
||||
Likes: toInt32(stats.Likes),
|
||||
Comments: toInt32(stats.Comments),
|
||||
Bookmarks: toInt32(stats.Bookmarks),
|
||||
Shares: toInt32(stats.Shares),
|
||||
TranslationCount: toInt32(stats.TranslationCount),
|
||||
ReadingTime: toInt(stats.ReadingTime),
|
||||
Complexity: &stats.Complexity,
|
||||
Sentiment: &stats.Sentiment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) {
|
||||
translationID, err := strconv.ParseUint(obj.ID, 10, 32)
|
||||
if err != nil {
|
||||
@ -1348,11 +1385,12 @@ func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation)
|
||||
// Convert domain model to GraphQL model
|
||||
return &model.TranslationStats{
|
||||
ID: fmt.Sprintf("%d", stats.ID),
|
||||
Views: &stats.Views,
|
||||
Likes: &stats.Likes,
|
||||
Comments: &stats.Comments,
|
||||
Shares: &stats.Shares,
|
||||
ReadingTime: &stats.ReadingTime,
|
||||
Views: toInt32(stats.Views),
|
||||
Likes: toInt32(stats.Likes),
|
||||
Comments: toInt32(stats.Comments),
|
||||
Shares: toInt32(stats.Shares),
|
||||
ReadingTime: toInt(stats.ReadingTime),
|
||||
Sentiment: &stats.Sentiment,
|
||||
}, nil
|
||||
}
|
||||
*/
|
||||
|
||||
@ -3,6 +3,8 @@ package analytics
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"tercul/internal/domain"
|
||||
"tercul/internal/jobs/linguistics"
|
||||
@ -32,20 +34,23 @@ type Service interface {
|
||||
|
||||
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
|
||||
UpdateTrending(ctx context.Context) error
|
||||
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo domain.AnalyticsRepository
|
||||
analysisRepo linguistics.AnalysisRepository
|
||||
translationRepo domain.TranslationRepository
|
||||
workRepo domain.WorkRepository
|
||||
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{
|
||||
repo: repo,
|
||||
analysisRepo: analysisRepo,
|
||||
translationRepo: translationRepo,
|
||||
workRepo: workRepo,
|
||||
sentimentProvider: sentimentProvider,
|
||||
}
|
||||
}
|
||||
@ -246,7 +251,51 @@ func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventTy
|
||||
return s.repo.UpdateUserEngagement(ctx, engagement)
|
||||
}
|
||||
|
||||
func (s *service) UpdateTrending(ctx context.Context) error {
|
||||
// TODO: Implement trending update
|
||||
return nil
|
||||
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
|
||||
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
|
||||
}
|
||||
|
||||
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)
|
||||
analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB)
|
||||
translationRepo := sql.NewTranslationRepository(s.DB)
|
||||
workRepo := sql.NewWorkRepository(s.DB)
|
||||
sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider()
|
||||
s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, sentimentProvider)
|
||||
s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, sentimentProvider)
|
||||
}
|
||||
|
||||
func (s *AnalyticsServiceTestSuite) SetupTest() {
|
||||
s.IntegrationTestSuite.SetupTest()
|
||||
s.DB.Exec("DELETE FROM trendings")
|
||||
}
|
||||
|
||||
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) {
|
||||
suite.Run(t, new(AnalyticsServiceTestSuite))
|
||||
}
|
||||
|
||||
@ -147,7 +147,7 @@ func (b *ApplicationBuilder) BuildApplication() error {
|
||||
|
||||
analyticsRepo := sql.NewAnalyticsRepository(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{
|
||||
AnalyticsService: analyticsService,
|
||||
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"tercul/internal/jobs/linguistics"
|
||||
syncjob "tercul/internal/jobs/sync"
|
||||
"tercul/internal/jobs/trending"
|
||||
"tercul/internal/platform/config"
|
||||
"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
|
||||
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.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 {
|
||||
if !allowedTranslationCounterFields[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
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) UpdateTrending(ctx context.Context, trending []domain.Trending) error {
|
||||
if len(trending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error {
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
return tx.Create(&trending).Error
|
||||
if len(trending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Insert new trending data
|
||||
if err := tx.Create(trending).Error; err != nil {
|
||||
return fmt.Errorf("failed to insert new trending data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@ -13,5 +13,6 @@ type AnalyticsRepository interface {
|
||||
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
|
||||
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() {
|
||||
// Arrange
|
||||
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{
|
||||
WorkID: work.ID,
|
||||
Analysis: domain.JSONB{
|
||||
"sentiment": 0.5678,
|
||||
},
|
||||
}
|
||||
s.DB.Create(textMetadata)
|
||||
s.DB.Create(readabilityScore)
|
||||
s.DB.Create(languageAnalysis)
|
||||
|
||||
// Act
|
||||
_, _, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID)
|
||||
returnedMetadata, returnedScore, returnedAnalysis, err := s.repo.GetAnalysisData(context.Background(), work.ID)
|
||||
|
||||
// Assert
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(returnedMetadata)
|
||||
s.Require().NotNil(returnedScore)
|
||||
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)
|
||||
s.Require().True(ok)
|
||||
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.AuthQueries = auth.NewAuthQueries(s.UserRepo, jwtManager)
|
||||
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)
|
||||
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 authors")
|
||||
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 {
|
||||
// Reset mock repositories
|
||||
if mockRepo, ok := s.WorkRepo.(*UnifiedMockWorkRepository); ok {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user