mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 04:01:34 +00:00
This commit introduces a new blog feature by implementing a JSON schema for blog posts and providing five example content files. Key changes: - Created a new directory structure for schemas and content (`schemas/`, `content/blog/`). - Implemented a JSON schema for blog posts, split into `blog.json` and `_defs.json` for reusability. - Added five example blog post files with full, realistic content. - Included a Python script (`validate.py`) to validate the example content against the schema.
302 lines
9.1 KiB
Go
302 lines
9.1 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"tercul/internal/domain"
|
|
"tercul/internal/jobs/linguistics"
|
|
"tercul/internal/platform/log"
|
|
"time"
|
|
)
|
|
|
|
type Service 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
|
|
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
|
|
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, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
|
|
return &service{
|
|
repo: repo,
|
|
analysisRepo: analysisRepo,
|
|
translationRepo: translationRepo,
|
|
workRepo: workRepo,
|
|
sentimentProvider: sentimentProvider,
|
|
}
|
|
}
|
|
|
|
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
|
|
}
|
|
|
|
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
|
|
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
|
|
}
|
|
|
|
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
|
|
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
|
|
}
|
|
|
|
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
|
|
return s.repo.GetOrCreateWorkStats(ctx, workID)
|
|
}
|
|
|
|
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) 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)
|
|
}
|