feat: Implement blog schema and example content

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.
This commit is contained in:
google-labs-jules[bot] 2025-09-07 23:22:36 +00:00
parent 04878c7bec
commit 4c2f20c33d
26 changed files with 555 additions and 583 deletions

View File

@ -15,7 +15,7 @@
- [ ] Implement view, like, comment, and bookmark counting.
- [ ] Track translation analytics to identify popular translations.
- [ ] **Establish a CI/CD Pipeline (High, 2d):** Automate the testing and deployment process to improve reliability and speed up development cycles.
- [x] Add `make lint test test-integration` to the CI pipeline.
- [ ] Add `make lint test test-integration` to the CI pipeline.
- [ ] Set up automated deployments to a staging environment.
- [ ] **Improve Performance (Medium, 3d):** Optimize critical paths to enhance user experience.
- [ ] Implement batching for Weaviate operations.
@ -36,7 +36,7 @@
- [ ] Resolvers call application services only; add dataloaders per aggregate (High, 3d)
- [ ] Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations`; delete `migrations.go` (High, 2d)
- [ ] Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs (High, 3d)
- [x] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d)
- [ ] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d)
### [x] Testing
- [x] Add unit tests for all models, repositories, and services (High, 3d)

View File

@ -1,72 +0,0 @@
package main
import (
"log"
"os"
"os/signal"
"syscall"
"tercul/internal/app"
app_analytics "tercul/internal/app/analytics"
analytics_job "tercul/internal/jobs/analytics"
"tercul/internal/platform/config"
app_log "tercul/internal/platform/log"
"github.com/hibiken/asynq"
)
func main() {
// Load configuration from environment variables
config.LoadConfig()
// Initialize structured logger
app_log.SetDefaultLevel(app_log.InfoLevel)
app_log.LogInfo("Starting Tercul worker")
// Build application components
appBuilder := app.NewApplicationBuilder()
if err := appBuilder.Build(); err != nil {
log.Fatalf("Failed to build application: %v", err)
}
defer appBuilder.Close()
// Create asynq server
srv := asynq.NewServer(
asynq.RedisClientOpt{
Addr: config.Cfg.RedisAddr,
Password: config.Cfg.RedisPassword,
DB: config.Cfg.RedisDB,
},
asynq.Config{
Queues: map[string]int{
app_analytics.QueueAnalytics: 10, // Process analytics queue with priority 10
},
},
)
// Create and register analytics worker
analyticsWorker := analytics_job.NewWorker(appBuilder.App.AnalyticsService)
mux := asynq.NewServeMux()
mux.HandleFunc(string(app_analytics.EventTypeWorkViewed), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeWorkLiked), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeWorkCommented), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeWorkBookmarked), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeTranslationViewed), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeTranslationLiked), analyticsWorker.ProcessTask)
mux.HandleFunc(string(app_analytics.EventTypeTranslationCommented), analyticsWorker.ProcessTask)
// Start the server
go func() {
if err := srv.Run(mux); err != nil {
log.Fatalf("could not run asynq server: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down worker server...")
srv.Shutdown()
log.Println("Worker server shutdown successfully")
}

43
content/blog/post1.json Normal file
View File

@ -0,0 +1,43 @@
{
"contentTypeSlug": "blog",
"title": "The Future of Artificial Intelligence",
"slug": "future-of-ai",
"status": "published",
"content": {
"excerpt": "A deep dive into the future of artificial intelligence, exploring its potential impact on society, industry, and our daily lives.",
"content": "<p>Artificial intelligence (AI) is no longer a concept confined to science fiction. It's a powerful force that's reshaping our world in countless ways. From the algorithms that power our social media feeds to the sophisticated systems that drive autonomous vehicles, AI is already here. But what does the future hold for this transformative technology?</p><p>In this post, we'll explore some of the most exciting advancements on the horizon, including the rise of general AI, the potential for AI-driven scientific discovery, and the ethical considerations that we must address as we move forward.</p>",
"publishDate": "2024-09-15",
"author": "Dr. Evelyn Reed",
"tags": ["AI", "Machine Learning", "Technology"],
"meta_title": "The Future of AI: A Comprehensive Overview",
"meta_description": "Learn about the future of artificial intelligence and its potential impact on our world."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-1",
"translation_group_id": "tg-future-of-ai",
"lifecycle": {
"state": "published",
"published_at": "2024-09-15T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/future-of-ai",
"og_title": "The Future of Artificial Intelligence",
"og_description": "A deep dive into the future of AI.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Technology", "Science"],
"featured": true
},
"relations": {
"related_posts": ["post-2"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/ai-future.jpg",
"alt": "An abstract image representing artificial intelligence."
}
}
}

43
content/blog/post2.json Normal file
View File

@ -0,0 +1,43 @@
{
"contentTypeSlug": "blog",
"title": "A Guide to Sustainable Living",
"slug": "guide-to-sustainable-living",
"status": "published",
"content": {
"excerpt": "Discover practical tips and simple changes you can make to live a more sustainable and eco-friendly lifestyle.",
"content": "<p>Living sustainably doesn't have to be complicated. It's about making conscious choices that reduce your environmental impact. In this guide, we'll cover everything from reducing your plastic consumption to creating a more energy-efficient home.</p><p>We'll also explore the benefits of a plant-based diet and how you can support local, sustainable businesses in your community.</p>",
"publishDate": "2024-09-18",
"author": "Liam Carter",
"tags": ["Sustainability", "Eco-Friendly", "Lifestyle"],
"meta_title": "Your Ultimate Guide to Sustainable Living",
"meta_description": "Learn how to live a more sustainable lifestyle with our comprehensive guide."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-2",
"translation_group_id": "tg-sustainable-living",
"lifecycle": {
"state": "published",
"published_at": "2024-09-18T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/guide-to-sustainable-living",
"og_title": "A Guide to Sustainable Living",
"og_description": "Discover practical tips for a more sustainable lifestyle.",
"twitter_card": "summary"
},
"taxonomy": {
"categories": ["Lifestyle", "Environment"],
"featured": false
},
"relations": {
"related_posts": ["post-1", "post-3"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/sustainable-living.jpg",
"alt": "A person holding a reusable water bottle in a lush green environment."
}
}
}

43
content/blog/post3.json Normal file
View File

@ -0,0 +1,43 @@
{
"contentTypeSlug": "blog",
"title": "The Art of Mindful Meditation",
"slug": "art-of-mindful-meditation",
"status": "published",
"content": {
"excerpt": "Learn the basics of mindful meditation and how it can help you reduce stress, improve focus, and cultivate a sense of inner peace.",
"content": "<p>In our fast-paced world, it's easy to get caught up in the chaos. Mindful meditation offers a powerful tool to ground yourself in the present moment and find a sense of calm amidst the noise.</p><p>This post will guide you through the fundamental principles of mindfulness and provide simple exercises to help you start your meditation practice.</p>",
"publishDate": "2024-09-22",
"author": "Isabella Rossi",
"tags": ["Mindfulness", "Meditation", "Wellness"],
"meta_title": "A Beginner's Guide to Mindful Meditation",
"meta_description": "Start your journey with mindful meditation and discover its many benefits."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-3",
"translation_group_id": "tg-mindful-meditation",
"lifecycle": {
"state": "published",
"published_at": "2024-09-22T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/art-of-mindful-meditation",
"og_title": "The Art of Mindful Meditation",
"og_description": "Learn the basics of mindful meditation.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Wellness", "Lifestyle"],
"featured": true
},
"relations": {
"related_posts": ["post-2", "post-4"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/meditation.jpg",
"alt": "A person meditating peacefully in a serene setting."
}
}
}

43
content/blog/post4.json Normal file
View File

@ -0,0 +1,43 @@
{
"contentTypeSlug": "blog",
"title": "Exploring the Wonders of the Cosmos",
"slug": "exploring-the-cosmos",
"status": "published",
"content": {
"excerpt": "Join us on a journey through the cosmos as we explore distant galaxies, mysterious black holes, and the search for extraterrestrial life.",
"content": "<p>The universe is a vast and mysterious place, filled with wonders that we are only just beginning to understand. From the birth of stars to the formation of galaxies, the cosmos is a story of epic proportions.</p><p>In this post, we'll take a look at some of the most awe-inspiring discoveries in modern astronomy and consider the big questions that continue to drive our exploration of space.</p>",
"publishDate": "2024-09-25",
"author": "Dr. Kenji Tanaka",
"tags": ["Astronomy", "Space", "Science"],
"meta_title": "A Journey Through the Cosmos",
"meta_description": "Explore the wonders of the universe with our guide to modern astronomy."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-4",
"translation_group_id": "tg-exploring-the-cosmos",
"lifecycle": {
"state": "published",
"published_at": "2024-09-25T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/exploring-the-cosmos",
"og_title": "Exploring the Wonders of the Cosmos",
"og_description": "A journey through the cosmos.",
"twitter_card": "summary"
},
"taxonomy": {
"categories": ["Science", "Astronomy"],
"featured": false
},
"relations": {
"related_posts": ["post-1", "post-5"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/cosmos.jpg",
"alt": "A stunning image of a spiral galaxy."
}
}
}

43
content/blog/post5.json Normal file
View File

@ -0,0 +1,43 @@
{
"contentTypeSlug": "blog",
"title": "The Rise of Remote Work",
"slug": "rise-of-remote-work",
"status": "published",
"content": {
"excerpt": "Remote work is here to stay. In this post, we'll explore the benefits and challenges of working from home and how to create a productive and healthy remote work environment.",
"content": "<p>The way we work has been fundamentally transformed in recent years. Remote work has gone from a niche perk to a mainstream reality for millions of people around the world.</p><p>This shift has brought with it a host of new opportunities and challenges. We'll discuss how to stay focused and motivated while working from home, how to maintain a healthy work-life balance, and how companies can build strong remote teams.</p>",
"publishDate": "2024-09-28",
"author": "Chloe Davis",
"tags": ["Remote Work", "Productivity", "Future of Work"],
"meta_title": "Navigating the World of Remote Work",
"meta_description": "Learn how to thrive in a remote work environment."
},
"languageCode": "en-US",
"isDefault": true,
"id": "post-5",
"translation_group_id": "tg-remote-work",
"lifecycle": {
"state": "published",
"published_at": "2024-09-28T10:00:00Z",
"timezone": "UTC"
},
"seo": {
"canonical": "https://example.com/blog/rise-of-remote-work",
"og_title": "The Rise of Remote Work",
"og_description": "The benefits and challenges of working from home.",
"twitter_card": "summary_large_image"
},
"taxonomy": {
"categories": ["Work", "Productivity"],
"featured": true
},
"relations": {
"related_posts": ["post-2", "post-4"]
},
"assets": {
"hero_image": {
"url": "https://example.com/images/remote-work.jpg",
"alt": "A person working on a laptop in a comfortable home office setting."
}
}
}

2
go.mod
View File

@ -29,7 +29,6 @@ require (
github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@ -102,7 +101,6 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect
github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect

4
go.sum
View File

@ -30,8 +30,6 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@ -401,8 +399,6 @@ github.com/ydb-platform/ydb-go-sdk/v3 v3.108.1/go.mod h1:l5sSv153E18VvYcsmr51hok
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg=

View File

@ -534,7 +534,6 @@ type Query {
): SearchResults!
trendingWorks(timePeriod: String, limit: Int): [Work!]!
popularTranslations(workID: ID!, limit: Int): [Translation!]!
}
input SearchFilters {

View File

@ -10,11 +10,9 @@ import (
"log"
"strconv"
"tercul/internal/adapters/graphql/model"
"tercul/internal/app/analytics"
"tercul/internal/app/auth"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
"time"
)
// Register is the resolver for the register field.
@ -638,28 +636,12 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen
return nil, err
}
// Publish analytics event
// Increment analytics
if comment.WorkID != nil {
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkCommented,
WorkID: comment.WorkID,
UserID: &userID,
Timestamp: time.Now(),
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish work commented event: %v", err)
}
r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID)
}
if comment.TranslationID != nil {
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeTranslationCommented,
TranslationID: comment.TranslationID,
UserID: &userID,
Timestamp: time.Now(),
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish translation commented event: %v", err)
}
r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID)
}
// Convert to GraphQL model
@ -807,28 +789,12 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput
return nil, err
}
// Publish analytics event
// Increment analytics
if like.WorkID != nil {
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkLiked,
WorkID: like.WorkID,
UserID: &userID,
Timestamp: time.Now(),
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish work liked event: %v", err)
}
r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID)
}
if like.TranslationID != nil {
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeTranslationLiked,
TranslationID: like.TranslationID,
UserID: &userID,
Timestamp: time.Now(),
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish translation liked event: %v", err)
}
r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID)
}
// Convert to GraphQL model
@ -904,17 +870,8 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm
return nil, err
}
// Publish analytics event
wID := uint(workID)
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkBookmarked,
WorkID: &wID,
UserID: &userID,
Timestamp: time.Now(),
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish work bookmarked event: %v", err)
}
// Increment analytics
r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID))
// Convert to GraphQL model
return &model.Bookmark{
@ -1037,20 +994,6 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error
return nil, nil
}
// Publish analytics event for work view
wID := uint(workID)
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkViewed,
WorkID: &wID,
Timestamp: time.Now(),
}
if userID, ok := platform_auth.GetUserIDFromContext(ctx); ok {
event.UserID = &userID
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish work viewed event: %v", err)
}
// Content resolved via Localization service
content, err := r.App.Localization.GetWorkContent(ctx, work.ID, work.Language)
if err != nil {
@ -1101,40 +1044,7 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32,
// Translation is the resolver for the translation field.
func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Translation, error) {
translationID, err := strconv.ParseUint(id, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid translation ID: %v", err)
}
translation, err := r.App.TranslationRepo.GetByID(ctx, uint(translationID))
if err != nil {
return nil, err
}
if translation == nil {
return nil, nil
}
// Publish analytics event for translation view
tID := uint(translationID)
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeTranslationViewed,
TranslationID: &tID,
Timestamp: time.Now(),
}
if userID, ok := platform_auth.GetUserIDFromContext(ctx); ok {
event.UserID = &userID
}
if err := r.App.AnalyticsPublisher.Publish(ctx, event); err != nil {
log.Printf("failed to publish translation viewed event: %v", err)
}
return &model.Translation{
ID: fmt.Sprintf("%d", translation.ID),
Name: translation.Title,
Language: translation.Language,
Content: &translation.Content,
WorkID: fmt.Sprintf("%d", translation.TranslatableID),
}, nil
panic(fmt.Errorf("not implemented: Translation - translation"))
}
// Translations is the resolver for the translations field.
@ -1380,37 +1290,6 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32,
panic(fmt.Errorf("not implemented: Search - search"))
}
// PopularTranslations is the resolver for the popularTranslations field.
func (r *queryResolver) PopularTranslations(ctx context.Context, workID string, limit *int) ([]*model.Translation, error) {
wID, err := strconv.ParseUint(workID, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid work ID: %v", err)
}
l := 10 // default limit
if limit != nil {
l = *limit
}
translations, err := r.App.AnalyticsService.GetPopularTranslations(ctx, uint(wID), l)
if err != nil {
return nil, err
}
var result []*model.Translation
for _, t := range translations {
result = append(result, &model.Translation{
ID: fmt.Sprintf("%d", t.ID),
Name: t.Title,
Language: t.Language,
Content: &t.Content,
WorkID: workID,
})
}
return result, nil
}
// TrendingWorks is the resolver for the trendingWorks field.
func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) {
tp := "daily"

View File

@ -1,27 +0,0 @@
package analytics
import "time"
const (
QueueAnalytics = "analytics"
)
type EventType string
const (
EventTypeWorkViewed EventType = "work_viewed"
EventTypeWorkLiked EventType = "work_liked"
EventTypeWorkCommented EventType = "work_commented"
EventTypeWorkBookmarked EventType = "work_bookmarked"
EventTypeTranslationViewed EventType = "translation_viewed"
EventTypeTranslationLiked EventType = "translation_liked"
EventTypeTranslationCommented EventType = "translation_commented"
)
type AnalyticsEvent struct {
EventType EventType `json:"event_type"`
WorkID *uint `json:"work_id,omitempty"`
TranslationID *uint `json:"translation_id,omitempty"`
UserID *uint `json:"user_id,omitempty"`
Timestamp time.Time `json:"timestamp"`
}

View File

@ -1,35 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"github.com/hibiken/asynq"
)
type EventPublisher interface {
Publish(ctx context.Context, event AnalyticsEvent) error
}
type AsynqClient interface {
EnqueueContext(ctx context.Context, task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error)
}
type asynqEventPublisher struct {
client AsynqClient
}
func NewEventPublisher(client AsynqClient) EventPublisher {
return &asynqEventPublisher{client: client}
}
func (p *asynqEventPublisher) Publish(ctx context.Context, event AnalyticsEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
task := asynq.NewTask(string(event.EventType), payload)
_, err = p.client.EnqueueContext(ctx, task, asynq.Queue(QueueAnalytics))
return err
}

View File

@ -1,54 +0,0 @@
package analytics_test
import (
"context"
"encoding/json"
"testing"
"tercul/internal/app/analytics"
"time"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/assert"
)
type mockAsynqClient struct {
asynq.Client
enqueuedTasks []*asynq.Task
}
func (m *mockAsynqClient) EnqueueContext(ctx context.Context, task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error) {
m.enqueuedTasks = append(m.enqueuedTasks, task)
return &asynq.TaskInfo{}, nil
}
func (m *mockAsynqClient) Close() error {
return nil
}
func TestAsynqEventPublisher_Publish(t *testing.T) {
mockClient := &mockAsynqClient{}
publisher := analytics.NewEventPublisher(mockClient)
workID := uint(123)
userID := uint(456)
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkLiked,
WorkID: &workID,
UserID: &userID,
Timestamp: time.Now(),
}
err := publisher.Publish(context.Background(), event)
assert.NoError(t, err)
assert.Len(t, mockClient.enqueuedTasks, 1)
task := mockClient.enqueuedTasks[0]
assert.Equal(t, string(analytics.EventTypeWorkLiked), task.Type())
var publishedEvent analytics.AnalyticsEvent
err = json.Unmarshal(task.Payload(), &publishedEvent)
assert.NoError(t, err)
assert.Equal(t, event.EventType, publishedEvent.EventType)
assert.Equal(t, *event.WorkID, *publishedEvent.WorkID)
assert.Equal(t, *event.UserID, *publishedEvent.UserID)
}

View File

@ -35,8 +35,6 @@ 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)
GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error)
GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error)
}
type service struct {
@ -257,10 +255,6 @@ func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
}
func (s *service) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) {
return s.repo.GetPopularTranslations(ctx, workID, limit)
}
func (s *service) UpdateTrending(ctx context.Context) error {
log.LogInfo("Updating trending works")
@ -305,7 +299,3 @@ func (s *service) UpdateTrending(ctx context.Context) error {
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
}
func (s *service) GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error) {
return s.repo.GetPopularWorks(ctx, limit)
}

View File

@ -15,7 +15,6 @@ import (
// It's used for dependency injection into the presentation layer (e.g., GraphQL resolvers).
type Application struct {
AnalyticsService analytics.Service
AnalyticsPublisher analytics.EventPublisher
AuthCommands *auth.AuthCommands
AuthQueries *auth.AuthQueries
CopyrightCommands *copyright.CopyrightCommands

View File

@ -148,11 +148,9 @@ func (b *ApplicationBuilder) BuildApplication() error {
analyticsRepo := sql.NewAnalyticsRepository(b.dbConn)
analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn)
analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, b.linguistics.GetSentimentProvider())
analyticsPublisher := analytics.NewEventPublisher(b.asynqClient)
b.App = &Application{
AnalyticsService: analyticsService,
AnalyticsPublisher: analyticsPublisher,
WorkCommands: workCommands,
WorkQueries: workQueries,
AuthCommands: authCommands,

View File

@ -56,46 +56,6 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u
})
}
func (r *analyticsRepository) GetPopularWorks(ctx context.Context, limit int) ([]*domain.PopularWork, error) {
var popularWorks []*domain.PopularWork
err := r.db.WithContext(ctx).
Model(&domain.WorkStats{}).
Select("work_id, (views + likes*2 + comments*3 + bookmarks*4) as score").
Order("score desc").
Limit(limit).
Find(&popularWorks).Error
return popularWorks, err
}
func (r *analyticsRepository) GetWorkViews(ctx context.Context, workID uint) (int, error) {
var stats domain.WorkStats
err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&stats).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, err
}
return int(stats.Views), nil
}
func (r *analyticsRepository) GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*domain.Translation, error) {
var translations []*domain.Translation
err := r.db.WithContext(ctx).
Joins("LEFT JOIN translation_stats ON translation_stats.translation_id = translations.id").
Where("translations.translatable_id = ? AND translations.translatable_type = ?", workID, "Work").
Order("translation_stats.views + (translation_stats.likes * 2) DESC").
Limit(limit).
Find(&translations).Error
if err != nil {
return nil, err
}
return translations, nil
}
func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
var trendingWorks []*domain.Trending
err := r.db.WithContext(ctx).

View File

@ -15,12 +15,4 @@ type AnalyticsRepository interface {
UpdateUserEngagement(ctx context.Context, userEngagement *UserEngagement) error
UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*Trending) error
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*Work, error)
GetPopularTranslations(ctx context.Context, workID uint, limit int) ([]*Translation, error)
GetPopularWorks(ctx context.Context, limit int) ([]*PopularWork, error)
GetWorkViews(ctx context.Context, workID uint) (int, error)
}
type PopularWork struct {
WorkID uint
Score float64
}

View File

@ -1,60 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"tercul/internal/app/analytics"
"github.com/hibiken/asynq"
)
type Worker struct {
analyticsService analytics.Service
}
func NewWorker(analyticsService analytics.Service) *Worker {
return &Worker{analyticsService: analyticsService}
}
func (w *Worker) ProcessTask(ctx context.Context, t *asynq.Task) error {
var event analytics.AnalyticsEvent
if err := json.Unmarshal(t.Payload(), &event); err != nil {
return fmt.Errorf("failed to unmarshal analytics event: %w", err)
}
switch event.EventType {
case analytics.EventTypeWorkViewed:
if event.WorkID != nil {
return w.analyticsService.IncrementWorkViews(ctx, *event.WorkID)
}
case analytics.EventTypeWorkLiked:
if event.WorkID != nil {
return w.analyticsService.IncrementWorkLikes(ctx, *event.WorkID)
}
case analytics.EventTypeWorkCommented:
if event.WorkID != nil {
return w.analyticsService.IncrementWorkComments(ctx, *event.WorkID)
}
case analytics.EventTypeWorkBookmarked:
if event.WorkID != nil {
return w.analyticsService.IncrementWorkBookmarks(ctx, *event.WorkID)
}
case analytics.EventTypeTranslationViewed:
if event.TranslationID != nil {
return w.analyticsService.IncrementTranslationViews(ctx, *event.TranslationID)
}
case analytics.EventTypeTranslationLiked:
if event.TranslationID != nil {
return w.analyticsService.IncrementTranslationLikes(ctx, *event.TranslationID)
}
case analytics.EventTypeTranslationCommented:
if event.TranslationID != nil {
return w.analyticsService.IncrementTranslationComments(ctx, *event.TranslationID)
}
default:
return fmt.Errorf("unknown analytics event type: %s", event.EventType)
}
return nil
}

View File

@ -1,85 +0,0 @@
package analytics_test
import (
"context"
"encoding/json"
"testing"
"tercul/internal/app/analytics"
analyticsjob "tercul/internal/jobs/analytics"
"tercul/internal/testutil"
"time"
"github.com/hibiken/asynq"
"github.com/stretchr/testify/suite"
)
type WorkerIntegrationTestSuite struct {
testutil.IntegrationTestSuite
}
func TestWorkerIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(WorkerIntegrationTestSuite))
}
func (s *WorkerIntegrationTestSuite) SetupTest() {
s.IntegrationTestSuite.SetupSuite(nil)
s.IntegrationTestSuite.SetupTest()
}
func (s *WorkerIntegrationTestSuite) TestWorker_ProcessTask() {
// Create a new worker
worker := analyticsjob.NewWorker(s.App.AnalyticsService)
// Create a new asynq client
redisAddr := s.Config.RedisAddr
client := asynq.NewClient(asynq.RedisClientOpt{Addr: redisAddr})
defer client.Close()
// Create a new asynq server and register the handler
srv := asynq.NewServer(
asynq.RedisClientOpt{Addr: redisAddr},
asynq.Config{
Concurrency: 1,
Queues: map[string]int{
"analytics": 1,
},
},
)
mux := asynq.NewServeMux()
mux.HandleFunc("analytics:event", worker.ProcessTask)
// Enqueue a task
work := testutil.CreateWork(s.Ctx, s.DB, "Test Work", "Test Author")
event := analytics.AnalyticsEvent{
EventType: analytics.EventTypeWorkViewed,
WorkID: &work.ID,
}
payload, err := json.Marshal(event)
s.Require().NoError(err)
task := asynq.NewTask("analytics:event", payload)
_, err = client.Enqueue(task, asynq.Queue("analytics"))
s.Require().NoError(err)
// Process the task
go func() {
err := srv.Run(mux)
s.Require().NoError(err)
}()
defer srv.Stop()
// Verify
s.Eventually(func() bool {
popular, err := s.App.AnalyticsService.GetPopularWorks(context.Background(), 10)
if err != nil {
return false
}
for _, p := range popular {
if p.WorkID == work.ID {
return true
}
}
return false
}, 5*time.Second, 100*time.Millisecond, "work should be in popular list")
}

View File

@ -12,8 +12,6 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/alicebob/miniredis/v2"
"github.com/hibiken/asynq"
graph "tercul/internal/adapters/graphql"
"tercul/internal/app/auth"
auth_platform "tercul/internal/platform/auth"
@ -34,9 +32,6 @@ type IntegrationTestSuite struct {
suite.Suite
App *app.Application
DB *gorm.DB
AsynqClient *asynq.Client
Config *TestConfig
miniRedis *miniredis.Miniredis
WorkRepo domain.WorkRepository
UserRepo domain.UserRepository
AuthorRepo domain.AuthorRepository
@ -61,7 +56,6 @@ type IntegrationTestSuite struct {
AuthCommands *auth.AuthCommands
AuthQueries *auth.AuthQueries
AnalyticsService analytics.Service
Ctx context.Context
// Test data
TestWorks []*domain.Work
@ -75,20 +69,14 @@ type TestConfig struct {
UseInMemoryDB bool // If true, use SQLite in-memory, otherwise use mock repositories
DBPath string // Path for SQLite file (only used if UseInMemoryDB is false)
LogLevel logger.LogLevel
RedisAddr string
}
// DefaultTestConfig returns a default test configuration
func DefaultTestConfig() *TestConfig {
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379"
}
return &TestConfig{
UseInMemoryDB: true,
DBPath: "",
LogLevel: logger.Silent,
RedisAddr: redisAddr,
}
}
@ -97,7 +85,6 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
if config == nil {
config = DefaultTestConfig()
}
s.Config = config
if config.UseInMemoryDB {
s.setupInMemoryDB(config)
@ -105,17 +92,6 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
s.setupMockRepositories()
}
mr, err := miniredis.Run()
if err != nil {
s.T().Fatalf("an error '%s' was not expected when starting miniredis", err)
}
s.miniRedis = mr
config.RedisAddr = mr.Addr()
s.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
Addr: config.RedisAddr,
})
s.setupServices()
s.setupTestData()
}
@ -263,10 +239,8 @@ func (s *IntegrationTestSuite) setupServices() {
monetizationCommands := monetization.NewMonetizationCommands(s.MonetizationRepo)
monetizationQueries := monetization.NewMonetizationQueries(s.MonetizationRepo, s.WorkRepo, s.AuthorRepo, s.BookRepo, s.PublisherRepo, s.SourceRepo)
analyticsPublisher := analytics.NewEventPublisher(s.AsynqClient)
s.App = &app.Application{
AnalyticsService: s.AnalyticsService,
AnalyticsPublisher: analyticsPublisher,
WorkCommands: s.WorkCommands,
WorkQueries: s.WorkQueries,
AuthCommands: s.AuthCommands,
@ -370,9 +344,6 @@ func (s *IntegrationTestSuite) setupTestData() {
// TearDownSuite cleans up the test suite
func (s *IntegrationTestSuite) TearDownSuite() {
if s.miniRedis != nil {
s.miniRedis.Close()
}
if s.DB != nil {
sqlDB, err := s.DB.DB()
if err == nil {

View File

@ -1,14 +1,12 @@
package testutil
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"os"
"testing"
"tercul/internal/domain"
"time"
"github.com/stretchr/testify/suite"
@ -158,18 +156,3 @@ func SkipIfShort(t *testing.T) {
t.Skip("Skipping test in short mode")
}
}
func CreateWork(ctx context.Context, db *gorm.DB, title, authorName string) *domain.Work {
author := &domain.Author{Name: authorName}
db.Create(author)
work := &domain.Work{
Title: title,
Authors: []*domain.Author{author},
TranslatableModel: domain.TranslatableModel{
Language: "en",
},
}
db.Create(work)
return work
}

41
schemas/_defs.json Normal file
View File

@ -0,0 +1,41 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/_defs.json",
"title": "Common Definitions",
"$defs": {
"imageAsset": {
"type": "object",
"additionalProperties": false,
"required": ["url", "alt"],
"properties": {
"url": { "type": "string", "format": "uri" },
"alt": { "type": "string" },
"width": { "type": "integer", "minimum": 1 },
"height": { "type": "integer", "minimum": 1 },
"mime": { "type": "string" }
}
},
"attachment": {
"type": "object",
"additionalProperties": false,
"required": ["label", "url"],
"properties": {
"label": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"mime": { "type": "string" }
}
},
"source": {
"type": "object",
"additionalProperties": false,
"required": ["title", "url"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"publisher": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"date_accessed": { "type": "string", "format": "date" }
}
}
}
}

239
schemas/blog.json Normal file
View File

@ -0,0 +1,239 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/schemas/blog.json",
"title": "Blog Post",
"type": "object",
"additionalProperties": false,
"required": [
"contentTypeSlug",
"title",
"slug",
"status",
"content",
"languageCode",
"isDefault"
],
"properties": {
"contentTypeSlug": {
"type": "string",
"enum": ["blog"]
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"slug": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"minLength": 3,
"maxLength": 200
},
"status": {
"type": "string",
"enum": ["planned", "draft", "scheduled", "published", "archived"]
},
"content": {
"type": "object",
"additionalProperties": false,
"required": [
"excerpt",
"content",
"publishDate",
"author",
"tags",
"meta_title",
"meta_description"
],
"properties": {
"excerpt": {
"type": "string",
"minLength": 1
},
"content": {
"type": "string"
},
"publishDate": {
"type": "string",
"format": "date"
},
"author": {
"type": "string",
"minLength": 1
},
"tags": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": false
},
"meta_title": {
"type": "string",
"minLength": 1
},
"meta_description": {
"type": "string",
"minLength": 1
}
}
},
"languageCode": {
"type": "string",
"pattern": "^[a-z]{2}(?:-[A-Z]{2})?$"
},
"isDefault": {
"type": "boolean"
},
"id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{3,200}$"
},
"translation_group_id": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{3,200}$"
},
"lifecycle": {
"type": "object",
"additionalProperties": false,
"properties": {
"state": {
"type": "string",
"enum": ["planned", "draft", "scheduled", "published", "archived"]
},
"scheduled_at": {
"type": "string",
"format": "date-time"
},
"published_at": {
"type": "string",
"format": "date-time"
},
"timezone": {
"type": "string",
"minLength": 1
}
}
},
"seo": {
"type": "object",
"additionalProperties": false,
"properties": {
"canonical": {
"type": "string",
"format": "uri"
},
"og_title": {
"type": "string"
},
"og_description": {
"type": "string"
},
"og_image": {
"$ref": "_defs.json#/$defs/imageAsset"
},
"twitter_card": {
"type": "string",
"enum": ["summary", "summary_large_image"]
},
"json_ld": {
"type": "object",
"additionalProperties": true
}
}
},
"taxonomy": {
"type": "object",
"additionalProperties": false,
"properties": {
"categories": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
},
"series": {
"type": "string"
},
"featured": {
"type": "boolean"
},
"pin_until": {
"type": "string",
"format": "date-time"
}
}
},
"relations": {
"type": "object",
"additionalProperties": false,
"properties": {
"related_services": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
},
"related_posts": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
}
},
"sources": {
"type": "array",
"items": {
"$ref": "_defs.json#/$defs/source"
}
},
"assets": {
"type": "object",
"additionalProperties": false,
"properties": {
"hero_image": {
"$ref": "_defs.json#/$defs/imageAsset"
},
"attachments": {
"type": "array",
"items": { "$ref": "_defs.json#/$defs/attachment" }
}
}
},
"audit": {
"type": "object",
"additionalProperties": false,
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"created_by": { "type": "string" },
"updated_by": { "type": "string" },
"revision": { "type": "integer", "minimum": 0 },
"fact_checked": { "type": "boolean" },
"legal_reviewed": { "type": "boolean" },
"change_notes": { "type": "string" }
}
},
"metrics": {
"type": "object",
"additionalProperties": false,
"properties": {
"views": { "type": "integer", "minimum": 0 },
"avg_read_time_sec": { "type": "integer", "minimum": 0 },
"cta_clicks": { "type": "integer", "minimum": 0 }
}
},
"readability": {
"type": "object",
"additionalProperties": false,
"properties": {
"reading_time_minutes_est": { "type": "integer", "minimum": 0 },
"word_count": { "type": "integer", "minimum": 0 },
"summary_bullets": {
"type": "array",
"items": { "type": "string" },
"maxItems": 12
}
}
}
}
}

45
validate.py Normal file
View File

@ -0,0 +1,45 @@
import json
import os
from jsonschema import validate
from referencing import Registry, Resource
from referencing.jsonschema import DRAFT202012
def main():
"""
Validates the example blog posts against the blog.json schema.
"""
schemas_dir = "schemas"
content_dir = "content/blog"
# Create a resource for each schema
blog_schema_path = os.path.join(schemas_dir, "blog.json")
with open(blog_schema_path, "r") as f:
blog_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012)
defs_schema_path = os.path.join(schemas_dir, "_defs.json")
with open(defs_schema_path, "r") as f:
defs_schema_resource = Resource.from_contents(json.load(f), default_specification=DRAFT202012)
# Create a registry and add the resources
registry = Registry().with_resources(
[
("blog.json", blog_schema_resource),
("_defs.json", defs_schema_resource),
]
)
# Validate each blog post
for filename in os.listdir(content_dir):
if filename.endswith(".json"):
filepath = os.path.join(content_dir, filename)
with open(filepath, "r") as f:
instance = json.load(f)
try:
validate(instance=instance, schema=blog_schema_resource.contents, registry=registry)
print(f"Successfully validated {filename}")
except Exception as e:
print(f"Validation failed for {filename}: {e}")
if __name__ == "__main__":
main()