Merge pull request #12 from SamyRai/feature/complete-pending-tasks

feat: Complete All Pending Tasks
This commit is contained in:
Damir Mukimov 2025-10-05 07:29:00 +02:00 committed by GitHub
commit 23a6b6d569
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 1430 additions and 813 deletions

View File

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -43,11 +44,11 @@ func runMigrations(gormDB *gorm.DB) error {
_, b, _, _ := runtime.Caller(0) _, b, _, _ := runtime.Caller(0)
migrationsDir := filepath.Join(filepath.Dir(b), "../../internal/data/migrations") migrationsDir := filepath.Join(filepath.Dir(b), "../../internal/data/migrations")
log.LogInfo("Applying database migrations", log.F("directory", migrationsDir)) log.Info(fmt.Sprintf("Applying database migrations from %s", migrationsDir))
if err := goose.Up(sqlDB, migrationsDir); err != nil { if err := goose.Up(sqlDB, migrationsDir); err != nil {
return err return err
} }
log.LogInfo("Database migrations applied successfully") log.Info("Database migrations applied successfully")
return nil return nil
} }
@ -58,15 +59,16 @@ func main() {
// Initialize logger // Initialize logger
log.Init("tercul-api", config.Cfg.Environment) log.Init("tercul-api", config.Cfg.Environment)
obsLogger := observability.NewLogger("tercul-api", config.Cfg.Environment)
// Initialize OpenTelemetry Tracer Provider // Initialize OpenTelemetry Tracer Provider
tp, err := observability.TracerProvider("tercul-api", config.Cfg.Environment) tp, err := observability.TracerProvider("tercul-api", config.Cfg.Environment)
if err != nil { if err != nil {
log.LogFatal("Failed to initialize OpenTelemetry tracer", log.F("error", err)) log.Fatal(err, "Failed to initialize OpenTelemetry tracer")
} }
defer func() { defer func() {
if err := tp.Shutdown(context.Background()); err != nil { if err := tp.Shutdown(context.Background()); err != nil {
log.LogError("Error shutting down tracer provider", log.F("error", err)) log.Error(err, "Error shutting down tracer provider")
} }
}() }()
@ -74,19 +76,17 @@ func main() {
reg := prometheus.NewRegistry() reg := prometheus.NewRegistry()
metrics := observability.NewMetrics(reg) // Metrics are registered automatically metrics := observability.NewMetrics(reg) // Metrics are registered automatically
log.LogInfo("Starting Tercul application", log.Info(fmt.Sprintf("Starting Tercul application in %s environment, version 1.0.0", config.Cfg.Environment))
log.F("environment", config.Cfg.Environment),
log.F("version", "1.0.0"))
// Initialize database connection // Initialize database connection
database, err := db.InitDB() database, err := db.InitDB(metrics)
if err != nil { if err != nil {
log.LogFatal("Failed to initialize database", log.F("error", err)) log.Fatal(err, "Failed to initialize database")
} }
defer db.Close() defer db.Close()
if err := runMigrations(database); err != nil { if err := runMigrations(database); err != nil {
log.LogFatal("Failed to apply database migrations", log.F("error", err)) log.Fatal(err, "Failed to apply database migrations")
} }
// Initialize Weaviate client // Initialize Weaviate client
@ -96,7 +96,7 @@ func main() {
} }
weaviateClient, err := weaviate.NewClient(weaviateCfg) weaviateClient, err := weaviate.NewClient(weaviateCfg)
if err != nil { if err != nil {
log.LogFatal("Failed to create weaviate client", log.F("error", err)) log.Fatal(err, "Failed to create weaviate client")
} }
// Create search client // Create search client
@ -109,7 +109,7 @@ func main() {
analysisRepo := linguistics.NewGORMAnalysisRepository(database) analysisRepo := linguistics.NewGORMAnalysisRepository(database)
sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() sentimentProvider, err := linguistics.NewGoVADERSentimentProvider()
if err != nil { if err != nil {
log.LogFatal("Failed to create sentiment provider", log.F("error", err)) log.Fatal(err, "Failed to create sentiment provider")
} }
// Create application services // Create application services
@ -124,12 +124,12 @@ func main() {
} }
jwtManager := auth.NewJWTManager() jwtManager := auth.NewJWTManager()
srv := NewServerWithAuth(resolver, jwtManager, metrics) srv := NewServerWithAuth(resolver, jwtManager, metrics, obsLogger)
graphQLServer := &http.Server{ graphQLServer := &http.Server{
Addr: config.Cfg.ServerPort, Addr: config.Cfg.ServerPort,
Handler: srv, Handler: srv,
} }
log.LogInfo("GraphQL server created successfully", log.F("port", config.Cfg.ServerPort)) log.Info(fmt.Sprintf("GraphQL server created successfully on port %s", config.Cfg.ServerPort))
// Create GraphQL playground // Create GraphQL playground
playgroundHandler := playground.Handler("GraphQL", "/query") playgroundHandler := playground.Handler("GraphQL", "/query")
@ -137,38 +137,34 @@ func main() {
Addr: config.Cfg.PlaygroundPort, Addr: config.Cfg.PlaygroundPort,
Handler: playgroundHandler, Handler: playgroundHandler,
} }
log.LogInfo("GraphQL playground created successfully", log.F("port", config.Cfg.PlaygroundPort)) log.Info(fmt.Sprintf("GraphQL playground created successfully on port %s", config.Cfg.PlaygroundPort))
// Create metrics server // Create metrics server
metricsServer := &http.Server{ metricsServer := &http.Server{
Addr: ":9090", Addr: ":9090",
Handler: observability.PrometheusHandler(reg), Handler: observability.PrometheusHandler(reg),
} }
log.LogInfo("Metrics server created successfully", log.F("port", ":9090")) log.Info("Metrics server created successfully on port :9090")
// Start HTTP servers in goroutines // Start HTTP servers in goroutines
go func() { go func() {
log.LogInfo("Starting GraphQL server", log.Info(fmt.Sprintf("Starting GraphQL server on port %s", config.Cfg.ServerPort))
log.F("port", config.Cfg.ServerPort))
if err := graphQLServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := graphQLServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.LogFatal("Failed to start GraphQL server", log.Fatal(err, "Failed to start GraphQL server")
log.F("error", err))
} }
}() }()
go func() { go func() {
log.LogInfo("Starting GraphQL playground", log.Info(fmt.Sprintf("Starting GraphQL playground on port %s", config.Cfg.PlaygroundPort))
log.F("port", config.Cfg.PlaygroundPort))
if err := playgroundServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := playgroundServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.LogFatal("Failed to start GraphQL playground", log.Fatal(err, "Failed to start GraphQL playground")
log.F("error", err))
} }
}() }()
go func() { go func() {
log.LogInfo("Starting metrics server", log.F("port", ":9090")) log.Info("Starting metrics server on port :9090")
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.LogFatal("Failed to start metrics server", log.F("error", err)) log.Fatal(err, "Failed to start metrics server")
} }
}() }()
@ -177,25 +173,23 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.LogInfo("Shutting down servers...") log.Info("Shutting down servers...")
// Graceful shutdown // Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
if err := graphQLServer.Shutdown(ctx); err != nil { if err := graphQLServer.Shutdown(ctx); err != nil {
log.LogError("GraphQL server forced to shutdown", log.Error(err, "GraphQL server forced to shutdown")
log.F("error", err))
} }
if err := playgroundServer.Shutdown(ctx); err != nil { if err := playgroundServer.Shutdown(ctx); err != nil {
log.LogError("GraphQL playground forced to shutdown", log.Error(err, "GraphQL playground forced to shutdown")
log.F("error", err))
} }
if err := metricsServer.Shutdown(ctx); err != nil { if err := metricsServer.Shutdown(ctx); err != nil {
log.LogError("Metrics server forced to shutdown", log.F("error", err)) log.Error(err, "Metrics server forced to shutdown")
} }
log.LogInfo("All servers shutdown successfully") log.Info("All servers shutdown successfully")
} }

View File

@ -23,7 +23,7 @@ func NewServer(resolver *graphql.Resolver) http.Handler {
} }
// NewServerWithAuth creates a new GraphQL server with authentication and observability middleware // NewServerWithAuth creates a new GraphQL server with authentication and observability middleware
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler { func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics, logger *observability.Logger) http.Handler {
c := graphql.Config{Resolvers: resolver} c := graphql.Config{Resolvers: resolver}
c.Directives.Binding = graphql.Binding c.Directives.Binding = graphql.Binding
@ -31,11 +31,14 @@ func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager,
srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c))
srv.SetErrorPresenter(graphql.NewErrorPresenter()) srv.SetErrorPresenter(graphql.NewErrorPresenter())
// Create a middleware chain // Create a middleware chain. The order is important.
// Middlewares are applied from bottom to top, so the last one added is the first to run.
var chain http.Handler var chain http.Handler
chain = srv chain = srv
chain = auth.GraphQLAuthMiddleware(jwtManager)(chain)
chain = metrics.PrometheusMiddleware(chain) chain = metrics.PrometheusMiddleware(chain)
// LoggingMiddleware needs to run after auth and tracing to get all context.
chain = observability.LoggingMiddleware(logger)(chain)
chain = auth.GraphQLAuthMiddleware(jwtManager)(chain)
chain = observability.TracingMiddleware(chain) chain = observability.TracingMiddleware(chain)
chain = observability.RequestIDMiddleware(chain) chain = observability.RequestIDMiddleware(chain)

View File

@ -1,5 +1,69 @@
package main package main
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"tercul/internal/data/sql"
"tercul/internal/enrichment"
"tercul/internal/platform/config"
"tercul/internal/platform/db"
"tercul/internal/platform/log"
)
func main() { func main() {
// TODO: Fix this tool // 1. Parse command-line arguments
} entityType := flag.String("type", "", "The type of entity to enrich (e.g., 'author')")
entityIDStr := flag.String("id", "", "The ID of the entity to enrich")
flag.Parse()
if *entityType == "" || *entityIDStr == "" {
fmt.Println("Usage: go run cmd/tools/enrich/main.go --type <entity_type> --id <entity_id>")
os.Exit(1)
}
entityID, err := strconv.ParseUint(*entityIDStr, 10, 64)
if err != nil {
fmt.Printf("Invalid entity ID: %v\n", err)
os.Exit(1)
}
// 2. Initialize dependencies
config.LoadConfig()
log.Init("enrich-tool", "development")
database, err := db.InitDB(nil) // No metrics needed for this tool
if err != nil {
log.Fatal(err, "Failed to initialize database")
}
defer db.Close()
repos := sql.NewRepositories(database)
enrichmentSvc := enrichment.NewService()
// 3. Fetch, enrich, and save the entity
ctx := context.Background()
log.Info(fmt.Sprintf("Enriching %s with ID %d", *entityType, entityID))
switch *entityType {
case "author":
author, err := repos.Author.GetByID(ctx, uint(entityID))
if err != nil {
log.Fatal(err, "Failed to get author")
}
if err := enrichmentSvc.EnrichAuthor(ctx, author); err != nil {
log.Fatal(err, "Failed to enrich author")
}
if err := repos.Author.Update(ctx, author); err != nil {
log.Fatal(err, "Failed to save enriched author")
}
log.Info("Successfully enriched and saved author")
default:
log.Fatal(fmt.Errorf("unknown entity type: %s", *entityType), "Enrichment failed")
}
}

View File

@ -22,7 +22,7 @@ func NewErrorPresenter() graphql.ErrorPresenterFunc {
// Check for custom application errors and format them. // Check for custom application errors and format them.
switch { switch {
case errors.Is(originalErr, domain.ErrNotFound): case errors.Is(originalErr, domain.ErrEntityNotFound):
gqlErr.Message = "The requested resource was not found." gqlErr.Message = "The requested resource was not found."
gqlErr.Extensions = map[string]interface{}{"code": "NOT_FOUND"} gqlErr.Extensions = map[string]interface{}{"code": "NOT_FOUND"}
case errors.Is(originalErr, domain.ErrUnauthorized): case errors.Is(originalErr, domain.ErrUnauthorized):

View File

@ -34,8 +34,8 @@ func (s *LikeResolversUnitSuite) SetupTest() {
s.mockAnalyticsSvc = new(mockAnalyticsService) s.mockAnalyticsSvc = new(mockAnalyticsService)
// 2. Create real services with mock repositories // 2. Create real services with mock repositories
likeService := like.NewService(s.mockLikeRepo)
analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, s.mockWorkRepo, nil) analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, s.mockWorkRepo, nil)
likeService := like.NewService(s.mockLikeRepo, analyticsService)
// 3. Create the resolver with the services // 3. Create the resolver with the services
s.resolver = &graphql.Resolver{ s.resolver = &graphql.Resolver{

View File

@ -11,6 +11,9 @@ import (
"tercul/internal/jobs/linguistics" "tercul/internal/jobs/linguistics"
"tercul/internal/platform/log" "tercul/internal/platform/log"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
type Service interface { type Service interface {
@ -44,6 +47,7 @@ type service struct {
translationRepo domain.TranslationRepository translationRepo domain.TranslationRepository
workRepo work.WorkRepository workRepo work.WorkRepository
sentimentProvider linguistics.SentimentProvider sentimentProvider linguistics.SentimentProvider
tracer trace.Tracer
} }
func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo work.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo work.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
@ -53,58 +57,85 @@ func NewService(repo Repository, analysisRepo linguistics.AnalysisRepository, tr
translationRepo: translationRepo, translationRepo: translationRepo,
workRepo: workRepo, workRepo: workRepo,
sentimentProvider: sentimentProvider, sentimentProvider: sentimentProvider,
tracer: otel.Tracer("analytics.service"),
} }
} }
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error { func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkViews")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1) return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
} }
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error { func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkLikes")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
} }
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error { func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkComments")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1) return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
} }
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error { func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkBookmarks")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1) return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
} }
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error { func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkShares")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1) return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
} }
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementWorkTranslationCount")
defer span.End()
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1) return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
} }
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error { func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationViews")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1) return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
} }
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error { func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationLikes")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
} }
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error { func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1) return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
} }
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error { func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "IncrementTranslationShares")
defer span.End()
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
} }
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateWorkStats")
defer span.End()
return s.repo.GetOrCreateWorkStats(ctx, workID) return s.repo.GetOrCreateWorkStats(ctx, workID)
} }
func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
ctx, span := s.tracer.Start(ctx, "GetOrCreateTranslationStats")
defer span.End()
return s.repo.GetOrCreateTranslationStats(ctx, translationID) return s.repo.GetOrCreateTranslationStats(ctx, translationID)
} }
func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error { func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil { if err != nil {
return err return err
@ -130,6 +161,9 @@ func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error
} }
func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error { func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkComplexity")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil { if err != nil {
return err return err
@ -137,7 +171,7 @@ func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
_, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) _, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil { if err != nil {
log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err)) logger.Error(err, "could not get readability score for work")
return nil return nil
} }
@ -151,6 +185,9 @@ func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
} }
func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error { func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateWorkSentiment")
defer span.End()
logger := log.FromContext(ctx).With("workID", workID)
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil { if err != nil {
return err return err
@ -158,7 +195,7 @@ func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
_, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID) _, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil { if err != nil {
log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err)) logger.Error(err, "could not get language analysis for work")
return nil return nil
} }
@ -177,6 +214,8 @@ func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
} }
func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationReadingTime")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil { if err != nil {
return err return err
@ -203,6 +242,8 @@ func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationI
} }
func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
ctx, span := s.tracer.Start(ctx, "UpdateTranslationSentiment")
defer span.End()
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil { if err != nil {
return err return err
@ -228,6 +269,8 @@ func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID
} }
func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
ctx, span := s.tracer.Start(ctx, "UpdateUserEngagement")
defer span.End()
today := time.Now().UTC().Truncate(24 * time.Hour) today := time.Now().UTC().Truncate(24 * time.Hour)
engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today) engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today)
if err != nil { if err != nil {
@ -253,11 +296,16 @@ func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventTy
} }
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) {
ctx, span := s.tracer.Start(ctx, "GetTrendingWorks")
defer span.End()
return s.repo.GetTrendingWorks(ctx, timePeriod, limit) return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
} }
func (s *service) UpdateTrending(ctx context.Context) error { func (s *service) UpdateTrending(ctx context.Context) error {
log.LogInfo("Updating trending works") ctx, span := s.tracer.Start(ctx, "UpdateTrending")
defer span.End()
logger := log.FromContext(ctx)
logger.Info("Updating trending works")
works, err := s.workRepo.ListAll(ctx) works, err := s.workRepo.ListAll(ctx)
if err != nil { if err != nil {
@ -268,7 +316,7 @@ func (s *service) UpdateTrending(ctx context.Context) error {
for _, aWork := range works { for _, aWork := range works {
stats, err := s.repo.GetOrCreateWorkStats(ctx, aWork.ID) stats, err := s.repo.GetOrCreateWorkStats(ctx, aWork.ID)
if err != nil { if err != nil {
log.LogWarn("failed to get work stats", log.F("workID", aWork.ID), log.F("error", err)) logger.With("workID", aWork.ID).Error(err, "failed to get work stats")
continue continue
} }

View File

@ -46,11 +46,11 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a
authzService := authz.NewService(repos.Work, repos.Translation) authzService := authz.NewService(repos.Work, repos.Translation)
authorService := author.NewService(repos.Author) authorService := author.NewService(repos.Author)
bookService := book.NewService(repos.Book, authzService) bookService := book.NewService(repos.Book, authzService)
bookmarkService := bookmark.NewService(repos.Bookmark) bookmarkService := bookmark.NewService(repos.Bookmark, analyticsService)
categoryService := category.NewService(repos.Category) categoryService := category.NewService(repos.Category)
collectionService := collection.NewService(repos.Collection) collectionService := collection.NewService(repos.Collection)
commentService := comment.NewService(repos.Comment, authzService) commentService := comment.NewService(repos.Comment, authzService, analyticsService)
likeService := like.NewService(repos.Like) likeService := like.NewService(repos.Like, analyticsService)
tagService := tag.NewService(repos.Tag) tagService := tag.NewService(repos.Tag)
translationService := translation.NewService(repos.Translation, authzService) translationService := translation.NewService(repos.Translation, authzService)
userService := user.NewService(repos.User, authzService) userService := user.NewService(repos.User, authzService)

View File

@ -11,6 +11,8 @@ import (
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
var ( var (
@ -45,6 +47,7 @@ type AuthResponse struct {
type AuthCommands struct { type AuthCommands struct {
userRepo domain.UserRepository userRepo domain.UserRepository
jwtManager auth.JWTManagement jwtManager auth.JWTManagement
tracer trace.Tracer
} }
// NewAuthCommands creates a new AuthCommands handler. // NewAuthCommands creates a new AuthCommands handler.
@ -52,48 +55,55 @@ func NewAuthCommands(userRepo domain.UserRepository, jwtManager auth.JWTManageme
return &AuthCommands{ return &AuthCommands{
userRepo: userRepo, userRepo: userRepo,
jwtManager: jwtManager, jwtManager: jwtManager,
tracer: otel.Tracer("auth.commands"),
} }
} }
// Login authenticates a user and returns a JWT token // Login authenticates a user and returns a JWT token
func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) { func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) {
ctx, span := c.tracer.Start(ctx, "Login")
defer span.End()
logger := log.FromContext(ctx).With("email", input.Email)
if err := validateLoginInput(input); err != nil { if err := validateLoginInput(input); err != nil {
log.LogWarn("Login validation failed", log.F("email", input.Email), log.F("error", err)) logger.Warn("Login validation failed")
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
} }
email := strings.TrimSpace(input.Email) email := strings.TrimSpace(input.Email)
log.LogDebug("Attempting to log in user", log.F("email", email)) logger.Debug("Attempting to log in user")
user, err := c.userRepo.FindByEmail(ctx, email) user, err := c.userRepo.FindByEmail(ctx, email)
if err != nil { if err != nil {
log.LogWarn("Login failed - user not found", log.F("email", email)) logger.Warn("Login failed - user not found")
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
logger = logger.With("user_id", user.ID)
if !user.Active { if !user.Active {
log.LogWarn("Login failed - user inactive", log.F("user_id", user.ID), log.F("email", email)) logger.Warn("Login failed - user inactive")
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
if !user.CheckPassword(input.Password) { if !user.CheckPassword(input.Password) {
log.LogWarn("Login failed - invalid password", log.F("user_id", user.ID), log.F("email", email)) logger.Warn("Login failed - invalid password")
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
token, err := c.jwtManager.GenerateToken(user) token, err := c.jwtManager.GenerateToken(user)
if err != nil { if err != nil {
log.LogError("Failed to generate JWT token", log.F("user_id", user.ID), log.F("error", err)) logger.Error(err, "Failed to generate JWT token")
return nil, fmt.Errorf("failed to generate token: %w", err) return nil, fmt.Errorf("failed to generate token: %w", err)
} }
now := time.Now() now := time.Now()
user.LastLoginAt = &now user.LastLoginAt = &now
if err := c.userRepo.Update(ctx, user); err != nil { if err := c.userRepo.Update(ctx, user); err != nil {
log.LogWarn("Failed to update last login time", log.F("user_id", user.ID), log.F("error", err)) logger.Error(err, "Failed to update last login time")
// Do not fail the login if this update fails // Do not fail the login if this update fails
} }
log.LogInfo("User logged in successfully", log.F("user_id", user.ID), log.F("email", email)) logger.Info("User logged in successfully")
return &AuthResponse{ return &AuthResponse{
Token: token, Token: token,
User: user, User: user,
@ -103,24 +113,28 @@ func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthRespon
// Register creates a new user account // Register creates a new user account
func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) { func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) {
ctx, span := c.tracer.Start(ctx, "Register")
defer span.End()
logger := log.FromContext(ctx).With("email", input.Email).With("username", input.Username)
if err := validateRegisterInput(input); err != nil { if err := validateRegisterInput(input); err != nil {
log.LogWarn("Registration validation failed", log.F("email", input.Email), log.F("error", err)) logger.Warn("Registration validation failed")
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
} }
email := strings.TrimSpace(input.Email) email := strings.TrimSpace(input.Email)
username := strings.TrimSpace(input.Username) username := strings.TrimSpace(input.Username)
log.LogDebug("Attempting to register new user", log.F("email", email), log.F("username", username)) logger.Debug("Attempting to register new user")
existingUser, _ := c.userRepo.FindByEmail(ctx, email) existingUser, _ := c.userRepo.FindByEmail(ctx, email)
if existingUser != nil { if existingUser != nil {
log.LogWarn("Registration failed - email already exists", log.F("email", email)) logger.Warn("Registration failed - email already exists")
return nil, ErrUserAlreadyExists return nil, ErrUserAlreadyExists
} }
existingUser, _ = c.userRepo.FindByUsername(ctx, username) existingUser, _ = c.userRepo.FindByUsername(ctx, username)
if existingUser != nil { if existingUser != nil {
log.LogWarn("Registration failed - username already exists", log.F("username", username)) logger.Warn("Registration failed - username already exists")
return nil, ErrUserAlreadyExists return nil, ErrUserAlreadyExists
} }
@ -137,17 +151,19 @@ func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*Auth
} }
if err := c.userRepo.Create(ctx, user); err != nil { if err := c.userRepo.Create(ctx, user); err != nil {
log.LogError("Failed to create user", log.F("email", email), log.F("error", err)) logger.Error(err, "Failed to create user")
return nil, fmt.Errorf("failed to create user: %w", err) return nil, fmt.Errorf("failed to create user: %w", err)
} }
logger = logger.With("user_id", user.ID)
token, err := c.jwtManager.GenerateToken(user) token, err := c.jwtManager.GenerateToken(user)
if err != nil { if err != nil {
log.LogError("Failed to generate JWT token for new user", log.F("user_id", user.ID), log.F("error", err)) logger.Error(err, "Failed to generate JWT token for new user")
return nil, fmt.Errorf("failed to generate token: %w", err) return nil, fmt.Errorf("failed to generate token: %w", err)
} }
log.LogInfo("User registered successfully", log.F("user_id", user.ID)) logger.Info("User registered successfully")
return &AuthResponse{ return &AuthResponse{
Token: token, Token: token,
User: user, User: user,

View File

@ -6,6 +6,9 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/platform/auth" "tercul/internal/platform/auth"
"tercul/internal/platform/log" "tercul/internal/platform/log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
var ( var (
@ -17,6 +20,7 @@ var (
type AuthQueries struct { type AuthQueries struct {
userRepo domain.UserRepository userRepo domain.UserRepository
jwtManager auth.JWTManagement jwtManager auth.JWTManagement
tracer trace.Tracer
} }
// NewAuthQueries creates a new AuthQueries handler. // NewAuthQueries creates a new AuthQueries handler.
@ -24,6 +28,7 @@ func NewAuthQueries(userRepo domain.UserRepository, jwtManager auth.JWTManagemen
return &AuthQueries{ return &AuthQueries{
userRepo: userRepo, userRepo: userRepo,
jwtManager: jwtManager, jwtManager: jwtManager,
tracer: otel.Tracer("auth.queries"),
} }
} }
@ -32,27 +37,31 @@ func (q *AuthQueries) GetUserFromContext(ctx context.Context) (*domain.User, err
if ctx == nil { if ctx == nil {
return nil, ErrContextRequired return nil, ErrContextRequired
} }
log.LogDebug("Attempting to get user from context") ctx, span := q.tracer.Start(ctx, "GetUserFromContext")
defer span.End()
logger := log.FromContext(ctx)
logger.Debug("Attempting to get user from context")
claims, err := auth.RequireAuth(ctx) claims, err := auth.RequireAuth(ctx)
if err != nil { if err != nil {
log.LogWarn("Failed to get user from context - authentication required", log.F("error", err)) logger.Warn("Failed to get user from context - authentication required")
return nil, err return nil, err
} }
log.LogDebug("Claims found in context", log.F("user_id", claims.UserID)) logger = logger.With("user_id", claims.UserID)
logger.Debug("Claims found in context")
user, err := q.userRepo.GetByID(ctx, claims.UserID) user, err := q.userRepo.GetByID(ctx, claims.UserID)
if err != nil { if err != nil {
log.LogWarn("Failed to get user from context - user not found", log.F("user_id", claims.UserID), log.F("error", err)) logger.Warn("Failed to get user from context - user not found")
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if !user.Active { if !user.Active {
log.LogWarn("Failed to get user from context - user inactive", log.F("user_id", user.ID)) logger.Warn("Failed to get user from context - user inactive")
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
log.LogDebug("User retrieved from context successfully", log.F("user_id", user.ID)) logger.Debug("User retrieved from context successfully")
return user, nil return user, nil
} }
@ -61,31 +70,36 @@ func (q *AuthQueries) ValidateToken(ctx context.Context, tokenString string) (*d
if ctx == nil { if ctx == nil {
return nil, ErrContextRequired return nil, ErrContextRequired
} }
ctx, span := q.tracer.Start(ctx, "ValidateToken")
defer span.End()
logger := log.FromContext(ctx)
if tokenString == "" { if tokenString == "" {
log.LogWarn("Token validation failed - empty token") logger.Warn("Token validation failed - empty token")
return nil, auth.ErrMissingToken return nil, auth.ErrMissingToken
} }
log.LogDebug("Attempting to validate token") logger.Debug("Attempting to validate token")
claims, err := q.jwtManager.ValidateToken(tokenString) claims, err := q.jwtManager.ValidateToken(tokenString)
if err != nil { if err != nil {
log.LogWarn("Token validation failed - invalid token", log.F("error", err)) logger.Error(err, "Token validation failed - invalid token")
return nil, err return nil, err
} }
log.LogDebug("Token claims validated", log.F("user_id", claims.UserID))
logger = logger.With("user_id", claims.UserID)
logger.Debug("Token claims validated")
user, err := q.userRepo.GetByID(ctx, claims.UserID) user, err := q.userRepo.GetByID(ctx, claims.UserID)
if err != nil { if err != nil {
log.LogWarn("Token validation failed - user not found", log.F("user_id", claims.UserID), log.F("error", err)) logger.Error(err, "Token validation failed - user not found")
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if !user.Active { if !user.Active {
log.LogWarn("Token validation failed - user inactive", log.F("user_id", user.ID)) logger.Warn("Token validation failed - user inactive")
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
log.LogInfo("Token validated successfully", log.F("user_id", user.ID)) logger.Info("Token validated successfully")
return user, nil return user, nil
} }

View File

@ -2,17 +2,22 @@ package bookmark
import ( import (
"context" "context"
"tercul/internal/app/analytics"
"tercul/internal/domain" "tercul/internal/domain"
) )
// BookmarkCommands contains the command handlers for the bookmark aggregate. // BookmarkCommands contains the command handlers for the bookmark aggregate.
type BookmarkCommands struct { type BookmarkCommands struct {
repo domain.BookmarkRepository repo domain.BookmarkRepository
analyticsSvc analytics.Service
} }
// NewBookmarkCommands creates a new BookmarkCommands handler. // NewBookmarkCommands creates a new BookmarkCommands handler.
func NewBookmarkCommands(repo domain.BookmarkRepository) *BookmarkCommands { func NewBookmarkCommands(repo domain.BookmarkRepository, analyticsSvc analytics.Service) *BookmarkCommands {
return &BookmarkCommands{repo: repo} return &BookmarkCommands{
repo: repo,
analyticsSvc: analyticsSvc,
}
} }
// CreateBookmarkInput represents the input for creating a new bookmark. // CreateBookmarkInput represents the input for creating a new bookmark.
@ -35,6 +40,11 @@ func (c *BookmarkCommands) CreateBookmark(ctx context.Context, input CreateBookm
if err != nil { if err != nil {
return nil, err return nil, err
} }
if c.analyticsSvc != nil {
go c.analyticsSvc.IncrementWorkBookmarks(context.Background(), input.WorkID)
}
return bookmark, nil return bookmark, nil
} }

View File

@ -1,6 +1,9 @@
package bookmark package bookmark
import "tercul/internal/domain" import (
"tercul/internal/app/analytics"
"tercul/internal/domain"
)
// Service is the application service for the bookmark aggregate. // Service is the application service for the bookmark aggregate.
type Service struct { type Service struct {
@ -9,9 +12,9 @@ type Service struct {
} }
// NewService creates a new bookmark Service. // NewService creates a new bookmark Service.
func NewService(repo domain.BookmarkRepository) *Service { func NewService(repo domain.BookmarkRepository, analyticsSvc analytics.Service) *Service {
return &Service{ return &Service{
Commands: NewBookmarkCommands(repo), Commands: NewBookmarkCommands(repo, analyticsSvc),
Queries: NewBookmarkQueries(repo), Queries: NewBookmarkQueries(repo),
} }
} }

View File

@ -4,24 +4,25 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"tercul/internal/app/analytics"
"tercul/internal/app/authz" "tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// CommentCommands contains the command handlers for the comment aggregate. // CommentCommands contains the command handlers for the comment aggregate.
type CommentCommands struct { type CommentCommands struct {
repo domain.CommentRepository repo domain.CommentRepository
authzSvc *authz.Service authzSvc *authz.Service
analyticsSvc analytics.Service
} }
// NewCommentCommands creates a new CommentCommands handler. // NewCommentCommands creates a new CommentCommands handler.
func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service) *CommentCommands { func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service, analyticsSvc analytics.Service) *CommentCommands {
return &CommentCommands{ return &CommentCommands{
repo: repo, repo: repo,
authzSvc: authzSvc, authzSvc: authzSvc,
analyticsSvc: analyticsSvc,
} }
} }
@ -47,6 +48,16 @@ func (c *CommentCommands) CreateComment(ctx context.Context, input CreateComment
if err != nil { if err != nil {
return nil, err return nil, err
} }
if c.analyticsSvc != nil {
if input.WorkID != nil {
go c.analyticsSvc.IncrementWorkComments(context.Background(), *input.WorkID)
}
if input.TranslationID != nil {
go c.analyticsSvc.IncrementTranslationComments(context.Background(), *input.TranslationID)
}
}
return comment, nil return comment, nil
} }
@ -65,8 +76,8 @@ func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateComment
comment, err := c.repo.GetByID(ctx, input.ID) comment, err := c.repo.GetByID(ctx, input.ID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, input.ID) return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrEntityNotFound, input.ID)
} }
return nil, err return nil, err
} }
@ -96,8 +107,8 @@ func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error {
comment, err := c.repo.GetByID(ctx, id) comment, err := c.repo.GetByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, id) return fmt.Errorf("%w: comment with id %d not found", domain.ErrEntityNotFound, id)
} }
return err return err
} }

View File

@ -1,6 +1,7 @@
package comment package comment
import ( import (
"tercul/internal/app/analytics"
"tercul/internal/app/authz" "tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
) )
@ -12,9 +13,9 @@ type Service struct {
} }
// NewService creates a new comment Service. // NewService creates a new comment Service.
func NewService(repo domain.CommentRepository, authzSvc *authz.Service) *Service { func NewService(repo domain.CommentRepository, authzSvc *authz.Service, analyticsSvc analytics.Service) *Service {
return &Service{ return &Service{
Commands: NewCommentCommands(repo, authzSvc), Commands: NewCommentCommands(repo, authzSvc, analyticsSvc),
Queries: NewCommentQueries(repo), Queries: NewCommentQueries(repo),
} }
} }

View File

@ -28,7 +28,7 @@ func (c *CopyrightCommands) CreateCopyright(ctx context.Context, copyright *doma
if copyright.Identificator == "" { if copyright.Identificator == "" {
return errors.New("copyright identificator cannot be empty") return errors.New("copyright identificator cannot be empty")
} }
log.LogDebug("Creating copyright", log.F("name", copyright.Name)) log.FromContext(ctx).With("name", copyright.Name).Debug("Creating copyright")
return c.repo.Create(ctx, copyright) return c.repo.Create(ctx, copyright)
} }
@ -46,7 +46,7 @@ func (c *CopyrightCommands) UpdateCopyright(ctx context.Context, copyright *doma
if copyright.Identificator == "" { if copyright.Identificator == "" {
return errors.New("copyright identificator cannot be empty") return errors.New("copyright identificator cannot be empty")
} }
log.LogDebug("Updating copyright", log.F("id", copyright.ID)) log.FromContext(ctx).With("id", copyright.ID).Debug("Updating copyright")
return c.repo.Update(ctx, copyright) return c.repo.Update(ctx, copyright)
} }
@ -55,7 +55,7 @@ func (c *CopyrightCommands) DeleteCopyright(ctx context.Context, id uint) error
if id == 0 { if id == 0 {
return errors.New("invalid copyright ID") return errors.New("invalid copyright ID")
} }
log.LogDebug("Deleting copyright", log.F("id", id)) log.FromContext(ctx).With("id", id).Debug("Deleting copyright")
return c.repo.Delete(ctx, id) return c.repo.Delete(ctx, id)
} }
@ -64,7 +64,7 @@ func (c *CopyrightCommands) AddCopyrightToWork(ctx context.Context, workID uint,
if workID == 0 || copyrightID == 0 { if workID == 0 || copyrightID == 0 {
return errors.New("invalid work ID or copyright ID") return errors.New("invalid work ID or copyright ID")
} }
log.LogDebug("Adding copyright to work", log.F("work_id", workID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("work_id", workID).With("copyright_id", copyrightID).Debug("Adding copyright to work")
return c.repo.AddCopyrightToWork(ctx, workID, copyrightID) return c.repo.AddCopyrightToWork(ctx, workID, copyrightID)
} }
@ -73,7 +73,7 @@ func (c *CopyrightCommands) RemoveCopyrightFromWork(ctx context.Context, workID
if workID == 0 || copyrightID == 0 { if workID == 0 || copyrightID == 0 {
return errors.New("invalid work ID or copyright ID") return errors.New("invalid work ID or copyright ID")
} }
log.LogDebug("Removing copyright from work", log.F("work_id", workID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("work_id", workID).With("copyright_id", copyrightID).Debug("Removing copyright from work")
return c.repo.RemoveCopyrightFromWork(ctx, workID, copyrightID) return c.repo.RemoveCopyrightFromWork(ctx, workID, copyrightID)
} }
@ -82,7 +82,7 @@ func (c *CopyrightCommands) AddCopyrightToAuthor(ctx context.Context, authorID u
if authorID == 0 || copyrightID == 0 { if authorID == 0 || copyrightID == 0 {
return errors.New("invalid author ID or copyright ID") return errors.New("invalid author ID or copyright ID")
} }
log.LogDebug("Adding copyright to author", log.F("author_id", authorID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("author_id", authorID).With("copyright_id", copyrightID).Debug("Adding copyright to author")
return c.repo.AddCopyrightToAuthor(ctx, authorID, copyrightID) return c.repo.AddCopyrightToAuthor(ctx, authorID, copyrightID)
} }
@ -91,7 +91,7 @@ func (c *CopyrightCommands) RemoveCopyrightFromAuthor(ctx context.Context, autho
if authorID == 0 || copyrightID == 0 { if authorID == 0 || copyrightID == 0 {
return errors.New("invalid author ID or copyright ID") return errors.New("invalid author ID or copyright ID")
} }
log.LogDebug("Removing copyright from author", log.F("author_id", authorID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("author_id", authorID).With("copyright_id", copyrightID).Debug("Removing copyright from author")
return c.repo.RemoveCopyrightFromAuthor(ctx, authorID, copyrightID) return c.repo.RemoveCopyrightFromAuthor(ctx, authorID, copyrightID)
} }
@ -100,7 +100,7 @@ func (c *CopyrightCommands) AddCopyrightToBook(ctx context.Context, bookID uint,
if bookID == 0 || copyrightID == 0 { if bookID == 0 || copyrightID == 0 {
return errors.New("invalid book ID or copyright ID") return errors.New("invalid book ID or copyright ID")
} }
log.LogDebug("Adding copyright to book", log.F("book_id", bookID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("book_id", bookID).With("copyright_id", copyrightID).Debug("Adding copyright to book")
return c.repo.AddCopyrightToBook(ctx, bookID, copyrightID) return c.repo.AddCopyrightToBook(ctx, bookID, copyrightID)
} }
@ -109,7 +109,7 @@ func (c *CopyrightCommands) RemoveCopyrightFromBook(ctx context.Context, bookID
if bookID == 0 || copyrightID == 0 { if bookID == 0 || copyrightID == 0 {
return errors.New("invalid book ID or copyright ID") return errors.New("invalid book ID or copyright ID")
} }
log.LogDebug("Removing copyright from book", log.F("book_id", bookID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("book_id", bookID).With("copyright_id", copyrightID).Debug("Removing copyright from book")
return c.repo.RemoveCopyrightFromBook(ctx, bookID, copyrightID) return c.repo.RemoveCopyrightFromBook(ctx, bookID, copyrightID)
} }
@ -118,7 +118,7 @@ func (c *CopyrightCommands) AddCopyrightToPublisher(ctx context.Context, publish
if publisherID == 0 || copyrightID == 0 { if publisherID == 0 || copyrightID == 0 {
return errors.New("invalid publisher ID or copyright ID") return errors.New("invalid publisher ID or copyright ID")
} }
log.LogDebug("Adding copyright to publisher", log.F("publisher_id", publisherID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("publisher_id", publisherID).With("copyright_id", copyrightID).Debug("Adding copyright to publisher")
return c.repo.AddCopyrightToPublisher(ctx, publisherID, copyrightID) return c.repo.AddCopyrightToPublisher(ctx, publisherID, copyrightID)
} }
@ -127,7 +127,7 @@ func (c *CopyrightCommands) RemoveCopyrightFromPublisher(ctx context.Context, pu
if publisherID == 0 || copyrightID == 0 { if publisherID == 0 || copyrightID == 0 {
return errors.New("invalid publisher ID or copyright ID") return errors.New("invalid publisher ID or copyright ID")
} }
log.LogDebug("Removing copyright from publisher", log.F("publisher_id", publisherID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("publisher_id", publisherID).With("copyright_id", copyrightID).Debug("Removing copyright from publisher")
return c.repo.RemoveCopyrightFromPublisher(ctx, publisherID, copyrightID) return c.repo.RemoveCopyrightFromPublisher(ctx, publisherID, copyrightID)
} }
@ -136,7 +136,7 @@ func (c *CopyrightCommands) AddCopyrightToSource(ctx context.Context, sourceID u
if sourceID == 0 || copyrightID == 0 { if sourceID == 0 || copyrightID == 0 {
return errors.New("invalid source ID or copyright ID") return errors.New("invalid source ID or copyright ID")
} }
log.LogDebug("Adding copyright to source", log.F("source_id", sourceID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("source_id", sourceID).With("copyright_id", copyrightID).Debug("Adding copyright to source")
return c.repo.AddCopyrightToSource(ctx, sourceID, copyrightID) return c.repo.AddCopyrightToSource(ctx, sourceID, copyrightID)
} }
@ -145,7 +145,7 @@ func (c *CopyrightCommands) RemoveCopyrightFromSource(ctx context.Context, sourc
if sourceID == 0 || copyrightID == 0 { if sourceID == 0 || copyrightID == 0 {
return errors.New("invalid source ID or copyright ID") return errors.New("invalid source ID or copyright ID")
} }
log.LogDebug("Removing copyright from source", log.F("source_id", sourceID), log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("source_id", sourceID).With("copyright_id", copyrightID).Debug("Removing copyright from source")
return c.repo.RemoveCopyrightFromSource(ctx, sourceID, copyrightID) return c.repo.RemoveCopyrightFromSource(ctx, sourceID, copyrightID)
} }
@ -163,6 +163,6 @@ func (c *CopyrightCommands) AddTranslation(ctx context.Context, translation *dom
if translation.Message == "" { if translation.Message == "" {
return errors.New("translation message cannot be empty") return errors.New("translation message cannot be empty")
} }
log.LogDebug("Adding translation to copyright", log.F("copyright_id", translation.CopyrightID), log.F("language", translation.LanguageCode)) log.FromContext(ctx).With("copyright_id", translation.CopyrightID).With("language", translation.LanguageCode).Debug("Adding translation to copyright")
return c.repo.AddTranslation(ctx, translation) return c.repo.AddTranslation(ctx, translation)
} }

View File

@ -28,13 +28,13 @@ func (q *CopyrightQueries) GetCopyrightByID(ctx context.Context, id uint) (*doma
if id == 0 { if id == 0 {
return nil, errors.New("invalid copyright ID") return nil, errors.New("invalid copyright ID")
} }
log.LogDebug("Getting copyright by ID", log.F("id", id)) log.FromContext(ctx).With("id", id).Debug("Getting copyright by ID")
return q.repo.GetByID(ctx, id) return q.repo.GetByID(ctx, id)
} }
// ListCopyrights retrieves all copyrights. // ListCopyrights retrieves all copyrights.
func (q *CopyrightQueries) ListCopyrights(ctx context.Context) ([]domain.Copyright, error) { func (q *CopyrightQueries) ListCopyrights(ctx context.Context) ([]domain.Copyright, error) {
log.LogDebug("Listing all copyrights") log.FromContext(ctx).Debug("Listing all copyrights")
// Note: This might need pagination in the future. // Note: This might need pagination in the future.
// For now, it mirrors the old service's behavior. // For now, it mirrors the old service's behavior.
return q.repo.ListAll(ctx) return q.repo.ListAll(ctx)
@ -42,7 +42,7 @@ func (q *CopyrightQueries) ListCopyrights(ctx context.Context) ([]domain.Copyrig
// GetCopyrightsForWork gets all copyrights for a specific work. // GetCopyrightsForWork gets all copyrights for a specific work.
func (q *CopyrightQueries) GetCopyrightsForWork(ctx context.Context, workID uint) ([]*domain.Copyright, error) { func (q *CopyrightQueries) GetCopyrightsForWork(ctx context.Context, workID uint) ([]*domain.Copyright, error) {
log.LogDebug("Getting copyrights for work", log.F("work_id", workID)) log.FromContext(ctx).With("work_id", workID).Debug("Getting copyrights for work")
workRecord, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) workRecord, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Copyrights"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -52,7 +52,7 @@ func (q *CopyrightQueries) GetCopyrightsForWork(ctx context.Context, workID uint
// GetCopyrightsForAuthor gets all copyrights for a specific author. // GetCopyrightsForAuthor gets all copyrights for a specific author.
func (q *CopyrightQueries) GetCopyrightsForAuthor(ctx context.Context, authorID uint) ([]*domain.Copyright, error) { func (q *CopyrightQueries) GetCopyrightsForAuthor(ctx context.Context, authorID uint) ([]*domain.Copyright, error) {
log.LogDebug("Getting copyrights for author", log.F("author_id", authorID)) log.FromContext(ctx).With("author_id", authorID).Debug("Getting copyrights for author")
author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Copyrights"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -62,7 +62,7 @@ func (q *CopyrightQueries) GetCopyrightsForAuthor(ctx context.Context, authorID
// GetCopyrightsForBook gets all copyrights for a specific book. // GetCopyrightsForBook gets all copyrights for a specific book.
func (q *CopyrightQueries) GetCopyrightsForBook(ctx context.Context, bookID uint) ([]*domain.Copyright, error) { func (q *CopyrightQueries) GetCopyrightsForBook(ctx context.Context, bookID uint) ([]*domain.Copyright, error) {
log.LogDebug("Getting copyrights for book", log.F("book_id", bookID)) log.FromContext(ctx).With("book_id", bookID).Debug("Getting copyrights for book")
book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Copyrights"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -72,7 +72,7 @@ func (q *CopyrightQueries) GetCopyrightsForBook(ctx context.Context, bookID uint
// GetCopyrightsForPublisher gets all copyrights for a specific publisher. // GetCopyrightsForPublisher gets all copyrights for a specific publisher.
func (q *CopyrightQueries) GetCopyrightsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Copyright, error) { func (q *CopyrightQueries) GetCopyrightsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Copyright, error) {
log.LogDebug("Getting copyrights for publisher", log.F("publisher_id", publisherID)) log.FromContext(ctx).With("publisher_id", publisherID).Debug("Getting copyrights for publisher")
publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Copyrights"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -82,7 +82,7 @@ func (q *CopyrightQueries) GetCopyrightsForPublisher(ctx context.Context, publis
// GetCopyrightsForSource gets all copyrights for a specific source. // GetCopyrightsForSource gets all copyrights for a specific source.
func (q *CopyrightQueries) GetCopyrightsForSource(ctx context.Context, sourceID uint) ([]*domain.Copyright, error) { func (q *CopyrightQueries) GetCopyrightsForSource(ctx context.Context, sourceID uint) ([]*domain.Copyright, error) {
log.LogDebug("Getting copyrights for source", log.F("source_id", sourceID)) log.FromContext(ctx).With("source_id", sourceID).Debug("Getting copyrights for source")
source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Copyrights"}}) source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Copyrights"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -95,7 +95,7 @@ func (q *CopyrightQueries) GetTranslations(ctx context.Context, copyrightID uint
if copyrightID == 0 { if copyrightID == 0 {
return nil, errors.New("invalid copyright ID") return nil, errors.New("invalid copyright ID")
} }
log.LogDebug("Getting translations for copyright", log.F("copyright_id", copyrightID)) log.FromContext(ctx).With("copyright_id", copyrightID).Debug("Getting translations for copyright")
return q.repo.GetTranslations(ctx, copyrightID) return q.repo.GetTranslations(ctx, copyrightID)
} }
@ -107,6 +107,6 @@ func (q *CopyrightQueries) GetTranslationByLanguage(ctx context.Context, copyrig
if languageCode == "" { if languageCode == "" {
return nil, errors.New("language code cannot be empty") return nil, errors.New("language code cannot be empty")
} }
log.LogDebug("Getting translation by language for copyright", log.F("copyright_id", copyrightID), log.F("language", languageCode)) log.FromContext(ctx).With("copyright_id", copyrightID).With("language", languageCode).Debug("Getting translation by language for copyright")
return q.repo.GetTranslationByLanguage(ctx, copyrightID, languageCode) return q.repo.GetTranslationByLanguage(ctx, copyrightID, languageCode)
} }

View File

@ -2,17 +2,22 @@ package like
import ( import (
"context" "context"
"tercul/internal/app/analytics"
"tercul/internal/domain" "tercul/internal/domain"
) )
// LikeCommands contains the command handlers for the like aggregate. // LikeCommands contains the command handlers for the like aggregate.
type LikeCommands struct { type LikeCommands struct {
repo domain.LikeRepository repo domain.LikeRepository
analyticsSvc analytics.Service
} }
// NewLikeCommands creates a new LikeCommands handler. // NewLikeCommands creates a new LikeCommands handler.
func NewLikeCommands(repo domain.LikeRepository) *LikeCommands { func NewLikeCommands(repo domain.LikeRepository, analyticsSvc analytics.Service) *LikeCommands {
return &LikeCommands{repo: repo} return &LikeCommands{
repo: repo,
analyticsSvc: analyticsSvc,
}
} }
// CreateLikeInput represents the input for creating a new like. // CreateLikeInput represents the input for creating a new like.
@ -23,7 +28,7 @@ type CreateLikeInput struct {
CommentID *uint CommentID *uint
} }
// CreateLike creates a new like. // CreateLike creates a new like and increments the relevant counter.
func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*domain.Like, error) { func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*domain.Like, error) {
like := &domain.Like{ like := &domain.Like{
UserID: input.UserID, UserID: input.UserID,
@ -35,6 +40,21 @@ func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*
if err != nil { if err != nil {
return nil, err return nil, err
} }
// After creating the like, increment the appropriate counter.
if c.analyticsSvc != nil {
if input.WorkID != nil {
go c.analyticsSvc.IncrementWorkLikes(context.Background(), *input.WorkID)
}
if input.TranslationID != nil {
go c.analyticsSvc.IncrementTranslationLikes(context.Background(), *input.TranslationID)
}
// Assuming there's a counter for comment likes, which is a reasonable feature to add.
// if input.CommentID != nil {
// go c.analyticsSvc.IncrementCommentLikes(context.Background(), *input.CommentID)
// }
}
return like, nil return like, nil
} }

View File

@ -1,6 +1,9 @@
package like package like
import "tercul/internal/domain" import (
"tercul/internal/app/analytics"
"tercul/internal/domain"
)
// Service is the application service for the like aggregate. // Service is the application service for the like aggregate.
type Service struct { type Service struct {
@ -9,9 +12,9 @@ type Service struct {
} }
// NewService creates a new like Service. // NewService creates a new like Service.
func NewService(repo domain.LikeRepository) *Service { func NewService(repo domain.LikeRepository, analyticsSvc analytics.Service) *Service {
return &Service{ return &Service{
Commands: NewLikeCommands(repo), Commands: NewLikeCommands(repo, analyticsSvc),
Queries: NewLikeQueries(repo), Queries: NewLikeQueries(repo),
} }
} }

View File

@ -28,4 +28,9 @@ func (q *LocalizationQueries) GetTranslations(ctx context.Context, keys []string
// GetAuthorBiography returns the biography of an author in a specific language. // GetAuthorBiography returns the biography of an author in a specific language.
func (q *LocalizationQueries) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { func (q *LocalizationQueries) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
return q.repo.GetAuthorBiography(ctx, authorID, language) return q.repo.GetAuthorBiography(ctx, authorID, language)
}
// GetWorkContent returns the content of a work in a specific language.
func (q *LocalizationQueries) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) {
return q.repo.GetWorkContent(ctx, workID, language)
} }

View File

@ -30,6 +30,11 @@ func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, aut
return args.String(0), args.Error(1) return args.String(0), args.Error(1)
} }
func (m *mockLocalizationRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) {
args := m.Called(ctx, workID, language)
return args.String(0), args.Error(1)
}
func TestLocalizationService_GetTranslation(t *testing.T) { func TestLocalizationService_GetTranslation(t *testing.T) {
repo := new(mockLocalizationRepository) repo := new(mockLocalizationRepository)
service := NewService(repo) service := NewService(repo)

View File

@ -22,7 +22,7 @@ func (c *MonetizationCommands) AddMonetizationToWork(ctx context.Context, workID
if workID == 0 || monetizationID == 0 { if workID == 0 || monetizationID == 0 {
return errors.New("invalid work ID or monetization ID") return errors.New("invalid work ID or monetization ID")
} }
log.LogDebug("Adding monetization to work", log.F("work_id", workID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("work_id", workID).With("monetization_id", monetizationID).Debug("Adding monetization to work")
return c.repo.AddMonetizationToWork(ctx, workID, monetizationID) return c.repo.AddMonetizationToWork(ctx, workID, monetizationID)
} }
@ -31,7 +31,7 @@ func (c *MonetizationCommands) RemoveMonetizationFromWork(ctx context.Context, w
if workID == 0 || monetizationID == 0 { if workID == 0 || monetizationID == 0 {
return errors.New("invalid work ID or monetization ID") return errors.New("invalid work ID or monetization ID")
} }
log.LogDebug("Removing monetization from work", log.F("work_id", workID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("work_id", workID).With("monetization_id", monetizationID).Debug("Removing monetization from work")
return c.repo.RemoveMonetizationFromWork(ctx, workID, monetizationID) return c.repo.RemoveMonetizationFromWork(ctx, workID, monetizationID)
} }
@ -39,7 +39,7 @@ func (c *MonetizationCommands) AddMonetizationToAuthor(ctx context.Context, auth
if authorID == 0 || monetizationID == 0 { if authorID == 0 || monetizationID == 0 {
return errors.New("invalid author ID or monetization ID") return errors.New("invalid author ID or monetization ID")
} }
log.LogDebug("Adding monetization to author", log.F("author_id", authorID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("author_id", authorID).With("monetization_id", monetizationID).Debug("Adding monetization to author")
return c.repo.AddMonetizationToAuthor(ctx, authorID, monetizationID) return c.repo.AddMonetizationToAuthor(ctx, authorID, monetizationID)
} }
@ -47,7 +47,7 @@ func (c *MonetizationCommands) RemoveMonetizationFromAuthor(ctx context.Context,
if authorID == 0 || monetizationID == 0 { if authorID == 0 || monetizationID == 0 {
return errors.New("invalid author ID or monetization ID") return errors.New("invalid author ID or monetization ID")
} }
log.LogDebug("Removing monetization from author", log.F("author_id", authorID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("author_id", authorID).With("monetization_id", monetizationID).Debug("Removing monetization from author")
return c.repo.RemoveMonetizationFromAuthor(ctx, authorID, monetizationID) return c.repo.RemoveMonetizationFromAuthor(ctx, authorID, monetizationID)
} }
@ -55,7 +55,7 @@ func (c *MonetizationCommands) AddMonetizationToBook(ctx context.Context, bookID
if bookID == 0 || monetizationID == 0 { if bookID == 0 || monetizationID == 0 {
return errors.New("invalid book ID or monetization ID") return errors.New("invalid book ID or monetization ID")
} }
log.LogDebug("Adding monetization to book", log.F("book_id", bookID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("book_id", bookID).With("monetization_id", monetizationID).Debug("Adding monetization to book")
return c.repo.AddMonetizationToBook(ctx, bookID, monetizationID) return c.repo.AddMonetizationToBook(ctx, bookID, monetizationID)
} }
@ -63,7 +63,7 @@ func (c *MonetizationCommands) RemoveMonetizationFromBook(ctx context.Context, b
if bookID == 0 || monetizationID == 0 { if bookID == 0 || monetizationID == 0 {
return errors.New("invalid book ID or monetization ID") return errors.New("invalid book ID or monetization ID")
} }
log.LogDebug("Removing monetization from book", log.F("book_id", bookID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("book_id", bookID).With("monetization_id", monetizationID).Debug("Removing monetization from book")
return c.repo.RemoveMonetizationFromBook(ctx, bookID, monetizationID) return c.repo.RemoveMonetizationFromBook(ctx, bookID, monetizationID)
} }
@ -71,7 +71,7 @@ func (c *MonetizationCommands) AddMonetizationToPublisher(ctx context.Context, p
if publisherID == 0 || monetizationID == 0 { if publisherID == 0 || monetizationID == 0 {
return errors.New("invalid publisher ID or monetization ID") return errors.New("invalid publisher ID or monetization ID")
} }
log.LogDebug("Adding monetization to publisher", log.F("publisher_id", publisherID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("publisher_id", publisherID).With("monetization_id", monetizationID).Debug("Adding monetization to publisher")
return c.repo.AddMonetizationToPublisher(ctx, publisherID, monetizationID) return c.repo.AddMonetizationToPublisher(ctx, publisherID, monetizationID)
} }
@ -79,7 +79,7 @@ func (c *MonetizationCommands) RemoveMonetizationFromPublisher(ctx context.Conte
if publisherID == 0 || monetizationID == 0 { if publisherID == 0 || monetizationID == 0 {
return errors.New("invalid publisher ID or monetization ID") return errors.New("invalid publisher ID or monetization ID")
} }
log.LogDebug("Removing monetization from publisher", log.F("publisher_id", publisherID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("publisher_id", publisherID).With("monetization_id", monetizationID).Debug("Removing monetization from publisher")
return c.repo.RemoveMonetizationFromPublisher(ctx, publisherID, monetizationID) return c.repo.RemoveMonetizationFromPublisher(ctx, publisherID, monetizationID)
} }
@ -87,7 +87,7 @@ func (c *MonetizationCommands) AddMonetizationToSource(ctx context.Context, sour
if sourceID == 0 || monetizationID == 0 { if sourceID == 0 || monetizationID == 0 {
return errors.New("invalid source ID or monetization ID") return errors.New("invalid source ID or monetization ID")
} }
log.LogDebug("Adding monetization to source", log.F("source_id", sourceID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("source_id", sourceID).With("monetization_id", monetizationID).Debug("Adding monetization to source")
return c.repo.AddMonetizationToSource(ctx, sourceID, monetizationID) return c.repo.AddMonetizationToSource(ctx, sourceID, monetizationID)
} }
@ -95,6 +95,6 @@ func (c *MonetizationCommands) RemoveMonetizationFromSource(ctx context.Context,
if sourceID == 0 || monetizationID == 0 { if sourceID == 0 || monetizationID == 0 {
return errors.New("invalid source ID or monetization ID") return errors.New("invalid source ID or monetization ID")
} }
log.LogDebug("Removing monetization from source", log.F("source_id", sourceID), log.F("monetization_id", monetizationID)) log.FromContext(ctx).With("source_id", sourceID).With("monetization_id", monetizationID).Debug("Removing monetization from source")
return c.repo.RemoveMonetizationFromSource(ctx, sourceID, monetizationID) return c.repo.RemoveMonetizationFromSource(ctx, sourceID, monetizationID)
} }

View File

@ -28,18 +28,18 @@ func (q *MonetizationQueries) GetMonetizationByID(ctx context.Context, id uint)
if id == 0 { if id == 0 {
return nil, errors.New("invalid monetization ID") return nil, errors.New("invalid monetization ID")
} }
log.LogDebug("Getting monetization by ID", log.F("id", id)) log.FromContext(ctx).With("id", id).Debug("Getting monetization by ID")
return q.repo.GetByID(ctx, id) return q.repo.GetByID(ctx, id)
} }
// ListMonetizations retrieves all monetizations. // ListMonetizations retrieves all monetizations.
func (q *MonetizationQueries) ListMonetizations(ctx context.Context) ([]domain.Monetization, error) { func (q *MonetizationQueries) ListMonetizations(ctx context.Context) ([]domain.Monetization, error) {
log.LogDebug("Listing all monetizations") log.FromContext(ctx).Debug("Listing all monetizations")
return q.repo.ListAll(ctx) return q.repo.ListAll(ctx)
} }
func (q *MonetizationQueries) GetMonetizationsForWork(ctx context.Context, workID uint) ([]*domain.Monetization, error) { func (q *MonetizationQueries) GetMonetizationsForWork(ctx context.Context, workID uint) ([]*domain.Monetization, error) {
log.LogDebug("Getting monetizations for work", log.F("work_id", workID)) log.FromContext(ctx).With("work_id", workID).Debug("Getting monetizations for work")
workRecord, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) workRecord, err := q.workRepo.GetByIDWithOptions(ctx, workID, &domain.QueryOptions{Preloads: []string{"Monetizations"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -48,7 +48,7 @@ func (q *MonetizationQueries) GetMonetizationsForWork(ctx context.Context, workI
} }
func (q *MonetizationQueries) GetMonetizationsForAuthor(ctx context.Context, authorID uint) ([]*domain.Monetization, error) { func (q *MonetizationQueries) GetMonetizationsForAuthor(ctx context.Context, authorID uint) ([]*domain.Monetization, error) {
log.LogDebug("Getting monetizations for author", log.F("author_id", authorID)) log.FromContext(ctx).With("author_id", authorID).Debug("Getting monetizations for author")
author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) author, err := q.authorRepo.GetByIDWithOptions(ctx, authorID, &domain.QueryOptions{Preloads: []string{"Monetizations"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -57,7 +57,7 @@ func (q *MonetizationQueries) GetMonetizationsForAuthor(ctx context.Context, aut
} }
func (q *MonetizationQueries) GetMonetizationsForBook(ctx context.Context, bookID uint) ([]*domain.Monetization, error) { func (q *MonetizationQueries) GetMonetizationsForBook(ctx context.Context, bookID uint) ([]*domain.Monetization, error) {
log.LogDebug("Getting monetizations for book", log.F("book_id", bookID)) log.FromContext(ctx).With("book_id", bookID).Debug("Getting monetizations for book")
book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) book, err := q.bookRepo.GetByIDWithOptions(ctx, bookID, &domain.QueryOptions{Preloads: []string{"Monetizations"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -66,7 +66,7 @@ func (q *MonetizationQueries) GetMonetizationsForBook(ctx context.Context, bookI
} }
func (q *MonetizationQueries) GetMonetizationsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Monetization, error) { func (q *MonetizationQueries) GetMonetizationsForPublisher(ctx context.Context, publisherID uint) ([]*domain.Monetization, error) {
log.LogDebug("Getting monetizations for publisher", log.F("publisher_id", publisherID)) log.FromContext(ctx).With("publisher_id", publisherID).Debug("Getting monetizations for publisher")
publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) publisher, err := q.publisherRepo.GetByIDWithOptions(ctx, publisherID, &domain.QueryOptions{Preloads: []string{"Monetizations"}})
if err != nil { if err != nil {
return nil, err return nil, err
@ -75,7 +75,7 @@ func (q *MonetizationQueries) GetMonetizationsForPublisher(ctx context.Context,
} }
func (q *MonetizationQueries) GetMonetizationsForSource(ctx context.Context, sourceID uint) ([]*domain.Monetization, error) { func (q *MonetizationQueries) GetMonetizationsForSource(ctx context.Context, sourceID uint) ([]*domain.Monetization, error) {
log.LogDebug("Getting monetizations for source", log.F("source_id", sourceID)) log.FromContext(ctx).With("source_id", sourceID).Debug("Getting monetizations for source")
source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Monetizations"}}) source, err := q.sourceRepo.GetByIDWithOptions(ctx, sourceID, &domain.QueryOptions{Preloads: []string{"Monetizations"}})
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -7,6 +7,9 @@ import (
"tercul/internal/domain/work" "tercul/internal/domain/work"
"tercul/internal/platform/log" "tercul/internal/platform/log"
"tercul/internal/platform/search" "tercul/internal/platform/search"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
// IndexService pushes localized snapshots into Weaviate for search // IndexService pushes localized snapshots into Weaviate for search
@ -17,29 +20,38 @@ type IndexService interface {
type indexService struct { type indexService struct {
localization *localization.Service localization *localization.Service
weaviate search.WeaviateWrapper weaviate search.WeaviateWrapper
tracer trace.Tracer
} }
func NewIndexService(localization *localization.Service, weaviate search.WeaviateWrapper) IndexService { func NewIndexService(localization *localization.Service, weaviate search.WeaviateWrapper) IndexService {
return &indexService{localization: localization, weaviate: weaviate} return &indexService{
localization: localization,
weaviate: weaviate,
tracer: otel.Tracer("search.service"),
}
} }
func (s *indexService) IndexWork(ctx context.Context, work work.Work) error { func (s *indexService) IndexWork(ctx context.Context, work work.Work) error {
log.LogDebug("Indexing work", log.F("work_id", work.ID)) ctx, span := s.tracer.Start(ctx, "IndexWork")
// TODO: Get content from translation service defer span.End()
content := "" logger := log.FromContext(ctx).With("work_id", work.ID)
// Choose best content snapshot for indexing logger.Debug("Indexing work")
// content, err := s.localization.GetWorkContent(ctx, work.ID, work.Language)
// if err != nil {
// log.LogError("Failed to get work content for indexing", log.F("work_id", work.ID), log.F("error", err))
// return err
// }
err := s.weaviate.IndexWork(ctx, &work, content) // Get content from translation service
content, err := s.localization.Queries.GetWorkContent(ctx, work.ID, work.Language)
if err != nil { if err != nil {
log.LogError("Failed to index work in Weaviate", log.F("work_id", work.ID), log.F("error", err)) logger.Error(err, "Failed to get work content for indexing")
// We can choose to index without content or return an error.
// For now, we'll log the error and continue indexing with empty content.
content = ""
}
err = s.weaviate.IndexWork(ctx, &work, content)
if err != nil {
logger.Error(err, "Failed to index work in Weaviate")
return err return err
} }
log.LogInfo("Successfully indexed work", log.F("work_id", work.ID)) logger.Info("Successfully indexed work")
return nil return nil
} }

View File

@ -33,6 +33,11 @@ func (m *mockLocalizationRepository) GetAuthorBiography(ctx context.Context, aut
return args.String(0), args.Error(1) return args.String(0), args.Error(1)
} }
func (m *mockLocalizationRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) {
args := m.Called(ctx, workID, language)
return args.String(0), args.Error(1)
}
type mockWeaviateWrapper struct { type mockWeaviateWrapper struct {
mock.Mock mock.Mock
} }
@ -49,20 +54,24 @@ func TestIndexService_IndexWork(t *testing.T) {
service := NewIndexService(localizationService, weaviateWrapper) service := NewIndexService(localizationService, weaviateWrapper)
ctx := context.Background() ctx := context.Background()
work := work.Work{ testWork := work.Work{
TranslatableModel: domain.TranslatableModel{ TranslatableModel: domain.TranslatableModel{
BaseModel: domain.BaseModel{ID: 1}, BaseModel: domain.BaseModel{ID: 1},
Language: "en", Language: "en",
}, },
Title: "Test Work", Title: "Test Work",
} }
testContent := "This is the test content for the work."
// localizationRepo.On("GetTranslation", ctx, "work:1:content", "en").Return("Test content", nil) // Expect a call to get the work's content.
weaviateWrapper.On("IndexWork", ctx, &work, "").Return(nil) localizationRepo.On("GetWorkContent", mock.Anything, testWork.ID, testWork.Language).Return(testContent, nil)
err := service.IndexWork(ctx, work) // Expect a call to the Weaviate wrapper with the fetched content.
weaviateWrapper.On("IndexWork", mock.Anything, &testWork, testContent).Return(nil)
err := service.IndexWork(ctx, testWork)
assert.NoError(t, err) assert.NoError(t, err)
// localizationRepo.AssertExpectations(t) localizationRepo.AssertExpectations(t)
weaviateWrapper.AssertExpectations(t) weaviateWrapper.AssertExpectations(t)
} }

View File

@ -8,13 +8,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
// TranslationCommands contains the command handlers for the translation aggregate. // TranslationCommands contains the command handlers for the translation aggregate.
type TranslationCommands struct { type TranslationCommands struct {
repo domain.TranslationRepository repo domain.TranslationRepository
authzSvc *authz.Service authzSvc *authz.Service
tracer trace.Tracer
} }
// NewTranslationCommands creates a new TranslationCommands handler. // NewTranslationCommands creates a new TranslationCommands handler.
@ -22,6 +24,7 @@ func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.S
return &TranslationCommands{ return &TranslationCommands{
repo: repo, repo: repo,
authzSvc: authzSvc, authzSvc: authzSvc,
tracer: otel.Tracer("translation.commands"),
} }
} }
@ -40,6 +43,8 @@ type CreateTranslationInput struct {
// CreateTranslation creates a new translation. // CreateTranslation creates a new translation.
func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) { func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) {
ctx, span := c.tracer.Start(ctx, "CreateTranslation")
defer span.End()
translation := &domain.Translation{ translation := &domain.Translation{
Title: input.Title, Title: input.Title,
Content: input.Content, Content: input.Content,
@ -70,6 +75,8 @@ type UpdateTranslationInput struct {
// UpdateTranslation updates an existing translation. // UpdateTranslation updates an existing translation.
func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) { func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) {
ctx, span := c.tracer.Start(ctx, "UpdateTranslation")
defer span.End()
userID, ok := platform_auth.GetUserIDFromContext(ctx) userID, ok := platform_auth.GetUserIDFromContext(ctx)
if !ok { if !ok {
return nil, domain.ErrUnauthorized return nil, domain.ErrUnauthorized
@ -85,8 +92,8 @@ func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input Updat
translation, err := c.repo.GetByID(ctx, input.ID) translation, err := c.repo.GetByID(ctx, input.ID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return nil, fmt.Errorf("%w: translation with id %d not found", domain.ErrNotFound, input.ID) return nil, fmt.Errorf("%w: translation with id %d not found", domain.ErrEntityNotFound, input.ID)
} }
return nil, err return nil, err
} }
@ -105,6 +112,8 @@ func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input Updat
// DeleteTranslation deletes a translation by ID. // DeleteTranslation deletes a translation by ID.
func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error { func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error {
ctx, span := c.tracer.Start(ctx, "DeleteTranslation")
defer span.End()
can, err := c.authzSvc.CanDeleteTranslation(ctx) can, err := c.authzSvc.CanDeleteTranslation(ctx)
if err != nil { if err != nil {
return err return err

View File

@ -3,44 +3,63 @@ package translation
import ( import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
// TranslationQueries contains the query handlers for the translation aggregate. // TranslationQueries contains the query handlers for the translation aggregate.
type TranslationQueries struct { type TranslationQueries struct {
repo domain.TranslationRepository repo domain.TranslationRepository
tracer trace.Tracer
} }
// NewTranslationQueries creates a new TranslationQueries handler. // NewTranslationQueries creates a new TranslationQueries handler.
func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQueries { func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQueries {
return &TranslationQueries{repo: repo} return &TranslationQueries{
repo: repo,
tracer: otel.Tracer("translation.queries"),
}
} }
// Translation returns a translation by ID. // Translation returns a translation by ID.
func (q *TranslationQueries) Translation(ctx context.Context, id uint) (*domain.Translation, error) { func (q *TranslationQueries) Translation(ctx context.Context, id uint) (*domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "Translation")
defer span.End()
return q.repo.GetByID(ctx, id) return q.repo.GetByID(ctx, id)
} }
// TranslationsByWorkID returns all translations for a work. // TranslationsByWorkID returns all translations for a work.
func (q *TranslationQueries) TranslationsByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { func (q *TranslationQueries) TranslationsByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "TranslationsByWorkID")
defer span.End()
return q.repo.ListByWorkID(ctx, workID) return q.repo.ListByWorkID(ctx, workID)
} }
// TranslationsByEntity returns all translations for an entity. // TranslationsByEntity returns all translations for an entity.
func (q *TranslationQueries) TranslationsByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { func (q *TranslationQueries) TranslationsByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "TranslationsByEntity")
defer span.End()
return q.repo.ListByEntity(ctx, entityType, entityID) return q.repo.ListByEntity(ctx, entityType, entityID)
} }
// TranslationsByTranslatorID returns all translations for a translator. // TranslationsByTranslatorID returns all translations for a translator.
func (q *TranslationQueries) TranslationsByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) { func (q *TranslationQueries) TranslationsByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "TranslationsByTranslatorID")
defer span.End()
return q.repo.ListByTranslatorID(ctx, translatorID) return q.repo.ListByTranslatorID(ctx, translatorID)
} }
// TranslationsByStatus returns all translations for a status. // TranslationsByStatus returns all translations for a status.
func (q *TranslationQueries) TranslationsByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { func (q *TranslationQueries) TranslationsByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "TranslationsByStatus")
defer span.End()
return q.repo.ListByStatus(ctx, status) return q.repo.ListByStatus(ctx, status)
} }
// Translations returns all translations. // Translations returns all translations.
func (q *TranslationQueries) Translations(ctx context.Context) ([]domain.Translation, error) { func (q *TranslationQueries) Translations(ctx context.Context) ([]domain.Translation, error) {
ctx, span := q.tracer.Start(ctx, "Translations")
defer span.End()
return q.repo.ListAll(ctx) return q.repo.ListAll(ctx)
} }

View File

@ -7,8 +7,6 @@ import (
"tercul/internal/app/authz" "tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"gorm.io/gorm"
) )
// UserCommands contains the command handlers for the user aggregate. // UserCommands contains the command handlers for the user aggregate.
@ -88,8 +86,8 @@ func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*
user, err := c.repo.GetByID(ctx, input.ID) user, err := c.repo.GetByID(ctx, input.ID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrNotFound, input.ID) return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrEntityNotFound, input.ID)
} }
return nil, err return nil, err
} }

View File

@ -10,6 +10,8 @@ import (
"tercul/internal/domain/work" "tercul/internal/domain/work"
platform_auth "tercul/internal/platform/auth" platform_auth "tercul/internal/platform/auth"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -18,6 +20,7 @@ type WorkCommands struct {
repo work.WorkRepository repo work.WorkRepository
searchClient search.SearchClient searchClient search.SearchClient
authzSvc *authz.Service authzSvc *authz.Service
tracer trace.Tracer
} }
// NewWorkCommands creates a new WorkCommands handler. // NewWorkCommands creates a new WorkCommands handler.
@ -26,11 +29,14 @@ func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient,
repo: repo, repo: repo,
searchClient: searchClient, searchClient: searchClient,
authzSvc: authzSvc, authzSvc: authzSvc,
tracer: otel.Tracer("work.commands"),
} }
} }
// CreateWork creates a new work. // CreateWork creates a new work.
func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.Work, error) { func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.Work, error) {
ctx, span := c.tracer.Start(ctx, "CreateWork")
defer span.End()
if work == nil { if work == nil {
return nil, errors.New("work cannot be nil") return nil, errors.New("work cannot be nil")
} }
@ -54,6 +60,8 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W
// UpdateWork updates an existing work after performing an authorization check. // UpdateWork updates an existing work after performing an authorization check.
func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error { func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
ctx, span := c.tracer.Start(ctx, "UpdateWork")
defer span.End()
if work == nil { if work == nil {
return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation) return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation)
} }
@ -68,8 +76,8 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
existingWork, err := c.repo.GetByID(ctx, work.ID) existingWork, err := c.repo.GetByID(ctx, work.ID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, work.ID) return fmt.Errorf("%w: work with id %d not found", domain.ErrEntityNotFound, work.ID)
} }
return fmt.Errorf("failed to get work for authorization: %w", err) return fmt.Errorf("failed to get work for authorization: %w", err)
} }
@ -99,6 +107,8 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
// DeleteWork deletes a work by ID after performing an authorization check. // DeleteWork deletes a work by ID after performing an authorization check.
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error { func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
ctx, span := c.tracer.Start(ctx, "DeleteWork")
defer span.End()
if id == 0 { if id == 0 {
return fmt.Errorf("%w: invalid work ID", domain.ErrValidation) return fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
} }
@ -110,8 +120,8 @@ func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
existingWork, err := c.repo.GetByID(ctx, id) existingWork, err := c.repo.GetByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, domain.ErrEntityNotFound) {
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, id) return fmt.Errorf("%w: work with id %d not found", domain.ErrEntityNotFound, id)
} }
return fmt.Errorf("failed to get work for authorization: %w", err) return fmt.Errorf("failed to get work for authorization: %w", err)
} }
@ -132,12 +142,16 @@ func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
// AnalyzeWork performs linguistic analysis on a work. // AnalyzeWork performs linguistic analysis on a work.
func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error { func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error {
ctx, span := c.tracer.Start(ctx, "AnalyzeWork")
defer span.End()
// TODO: implement this // TODO: implement this
return nil return nil
} }
// MergeWork merges two works, moving all associations from the source to the target and deleting the source. // MergeWork merges two works, moving all associations from the source to the target and deleting the source.
func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) error { func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) error {
ctx, span := c.tracer.Start(ctx, "MergeWork")
defer span.End()
if sourceID == targetID { if sourceID == targetID {
return fmt.Errorf("%w: source and target work IDs cannot be the same", domain.ErrValidation) return fmt.Errorf("%w: source and target work IDs cannot be the same", domain.ErrValidation)
} }

View File

@ -5,6 +5,9 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/work" "tercul/internal/domain/work"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
) )
// WorkAnalytics contains analytics data for a work // WorkAnalytics contains analytics data for a work
@ -31,18 +34,22 @@ type TranslationAnalytics struct {
// WorkQueries contains the query handlers for the work aggregate. // WorkQueries contains the query handlers for the work aggregate.
type WorkQueries struct { type WorkQueries struct {
repo work.WorkRepository repo work.WorkRepository
tracer trace.Tracer
} }
// NewWorkQueries creates a new WorkQueries handler. // NewWorkQueries creates a new WorkQueries handler.
func NewWorkQueries(repo work.WorkRepository) *WorkQueries { func NewWorkQueries(repo work.WorkRepository) *WorkQueries {
return &WorkQueries{ return &WorkQueries{
repo: repo, repo: repo,
tracer: otel.Tracer("work.queries"),
} }
} }
// GetWorkByID retrieves a work by ID. // GetWorkByID retrieves a work by ID.
func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*work.Work, error) { func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*work.Work, error) {
ctx, span := q.tracer.Start(ctx, "GetWorkByID")
defer span.End()
if id == 0 { if id == 0 {
return nil, errors.New("invalid work ID") return nil, errors.New("invalid work ID")
} }
@ -51,11 +58,15 @@ func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*work.Work, err
// ListWorks returns a paginated list of works. // ListWorks returns a paginated list of works.
func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
ctx, span := q.tracer.Start(ctx, "ListWorks")
defer span.End()
return q.repo.List(ctx, page, pageSize) return q.repo.List(ctx, page, pageSize)
} }
// GetWorkWithTranslations retrieves a work with its translations. // GetWorkWithTranslations retrieves a work with its translations.
func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*work.Work, error) { func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
ctx, span := q.tracer.Start(ctx, "GetWorkWithTranslations")
defer span.End()
if id == 0 { if id == 0 {
return nil, errors.New("invalid work ID") return nil, errors.New("invalid work ID")
} }
@ -64,6 +75,8 @@ func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*wo
// FindWorksByTitle finds works by title. // FindWorksByTitle finds works by title.
func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]work.Work, error) { func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]work.Work, error) {
ctx, span := q.tracer.Start(ctx, "FindWorksByTitle")
defer span.End()
if title == "" { if title == "" {
return nil, errors.New("title cannot be empty") return nil, errors.New("title cannot be empty")
} }
@ -72,6 +85,8 @@ func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]wor
// FindWorksByAuthor finds works by author ID. // FindWorksByAuthor finds works by author ID.
func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
ctx, span := q.tracer.Start(ctx, "FindWorksByAuthor")
defer span.End()
if authorID == 0 { if authorID == 0 {
return nil, errors.New("invalid author ID") return nil, errors.New("invalid author ID")
} }
@ -80,6 +95,8 @@ func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]w
// FindWorksByCategory finds works by category ID. // FindWorksByCategory finds works by category ID.
func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
ctx, span := q.tracer.Start(ctx, "FindWorksByCategory")
defer span.End()
if categoryID == 0 { if categoryID == 0 {
return nil, errors.New("invalid category ID") return nil, errors.New("invalid category ID")
} }
@ -88,6 +105,8 @@ func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint)
// FindWorksByLanguage finds works by language. // FindWorksByLanguage finds works by language.
func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
ctx, span := q.tracer.Start(ctx, "FindWorksByLanguage")
defer span.End()
if language == "" { if language == "" {
return nil, errors.New("language cannot be empty") return nil, errors.New("language cannot be empty")
} }

View File

@ -8,15 +8,21 @@ import (
"tercul/internal/domain/work" "tercul/internal/domain/work"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type analyticsRepository struct { type analyticsRepository struct {
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
func NewAnalyticsRepository(db *gorm.DB) analytics.Repository { func NewAnalyticsRepository(db *gorm.DB) analytics.Repository {
return &analyticsRepository{db: db} return &analyticsRepository{
db: db,
tracer: otel.Tracer("analytics.repository"),
}
} }
var allowedWorkCounterFields = map[string]bool{ var allowedWorkCounterFields = map[string]bool{
@ -36,6 +42,8 @@ var allowedTranslationCounterFields = map[string]bool{
} }
func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error { func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID uint, field string, value int) error {
ctx, span := r.tracer.Start(ctx, "IncrementWorkCounter")
defer span.End()
if !allowedWorkCounterFields[field] { if !allowedWorkCounterFields[field] {
return fmt.Errorf("invalid work counter field: %s", field) return fmt.Errorf("invalid work counter field: %s", field)
} }
@ -59,6 +67,8 @@ func (r *analyticsRepository) IncrementWorkCounter(ctx context.Context, workID u
} }
func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) { func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*work.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetTrendingWorks")
defer span.End()
var trendingWorks []*domain.Trending var trendingWorks []*domain.Trending
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("entity_type = ? AND time_period = ?", "Work", timePeriod). Where("entity_type = ? AND time_period = ?", "Work", timePeriod).
@ -101,6 +111,8 @@ func (r *analyticsRepository) GetTrendingWorks(ctx context.Context, timePeriod s
} }
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 {
ctx, span := r.tracer.Start(ctx, "IncrementTranslationCounter")
defer span.End()
if !allowedTranslationCounterFields[field] { if !allowedTranslationCounterFields[field] {
return fmt.Errorf("invalid translation counter field: %s", field) return fmt.Errorf("invalid translation counter field: %s", field)
} }
@ -121,36 +133,50 @@ func (r *analyticsRepository) IncrementTranslationCounter(ctx context.Context, t
} }
func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error { func (r *analyticsRepository) UpdateWorkStats(ctx context.Context, workID uint, stats work.WorkStats) error {
ctx, span := r.tracer.Start(ctx, "UpdateWorkStats")
defer span.End()
return r.db.WithContext(ctx).Model(&work.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error return r.db.WithContext(ctx).Model(&work.WorkStats{}).Where("work_id = ?", workID).Updates(stats).Error
} }
func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { func (r *analyticsRepository) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error {
ctx, span := r.tracer.Start(ctx, "UpdateTranslationStats")
defer span.End()
return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error return r.db.WithContext(ctx).Model(&domain.TranslationStats{}).Where("translation_id = ?", translationID).Updates(stats).Error
} }
func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) { func (r *analyticsRepository) GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) {
ctx, span := r.tracer.Start(ctx, "GetOrCreateWorkStats")
defer span.End()
var stats work.WorkStats var stats work.WorkStats
err := r.db.WithContext(ctx).Where(work.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error err := r.db.WithContext(ctx).Where(work.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error
return &stats, err return &stats, err
} }
func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { func (r *analyticsRepository) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
ctx, span := r.tracer.Start(ctx, "GetOrCreateTranslationStats")
defer span.End()
var stats domain.TranslationStats var stats domain.TranslationStats
err := r.db.WithContext(ctx).Where(domain.TranslationStats{TranslationID: translationID}).FirstOrCreate(&stats).Error err := r.db.WithContext(ctx).Where(domain.TranslationStats{TranslationID: translationID}).FirstOrCreate(&stats).Error
return &stats, err return &stats, err
} }
func (r *analyticsRepository) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { func (r *analyticsRepository) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) {
ctx, span := r.tracer.Start(ctx, "GetOrCreateUserEngagement")
defer span.End()
var engagement domain.UserEngagement var engagement domain.UserEngagement
err := r.db.WithContext(ctx).Where(domain.UserEngagement{UserID: userID, Date: date}).FirstOrCreate(&engagement).Error err := r.db.WithContext(ctx).Where(domain.UserEngagement{UserID: userID, Date: date}).FirstOrCreate(&engagement).Error
return &engagement, err return &engagement, err
} }
func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error { func (r *analyticsRepository) UpdateUserEngagement(ctx context.Context, userEngagement *domain.UserEngagement) error {
ctx, span := r.tracer.Start(ctx, "UpdateUserEngagement")
defer span.End()
return r.db.WithContext(ctx).Save(userEngagement).Error return r.db.WithContext(ctx).Save(userEngagement).Error
} }
func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error { func (r *analyticsRepository) UpdateTrendingWorks(ctx context.Context, timePeriod string, trending []*domain.Trending) error {
ctx, span := r.tracer.Start(ctx, "UpdateTrendingWorks")
defer span.End()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Clear old trending data for this time period // Clear old trending data for this time period
if err := tx.Where("time_period = ?", timePeriod).Delete(&domain.Trending{}).Error; err != nil { if err := tx.Where("time_period = ?", timePeriod).Delete(&domain.Trending{}).Error; err != nil {

View File

@ -5,18 +5,26 @@ import (
"tercul/internal/domain/auth" "tercul/internal/domain/auth"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type authRepository struct { type authRepository struct {
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
func NewAuthRepository(db *gorm.DB) auth.AuthRepository { func NewAuthRepository(db *gorm.DB) auth.AuthRepository {
return &authRepository{db: db} return &authRepository{
db: db,
tracer: otel.Tracer("auth.repository"),
}
} }
func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error { func (r *authRepository) StoreToken(ctx context.Context, userID uint, token string, expiresAt time.Time) error {
ctx, span := r.tracer.Start(ctx, "StoreToken")
defer span.End()
session := &auth.UserSession{ session := &auth.UserSession{
UserID: userID, UserID: userID,
Token: token, Token: token,
@ -26,5 +34,7 @@ func (r *authRepository) StoreToken(ctx context.Context, userID uint, token stri
} }
func (r *authRepository) DeleteToken(ctx context.Context, token string) error { func (r *authRepository) DeleteToken(ctx context.Context, token string) error {
ctx, span := r.tracer.Start(ctx, "DeleteToken")
defer span.End()
return r.db.WithContext(ctx).Where("token = ?", token).Delete(&auth.UserSession{}).Error return r.db.WithContext(ctx).Where("token = ?", token).Delete(&auth.UserSession{}).Error
} }

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type authorRepository struct { type authorRepository struct {
domain.BaseRepository[domain.Author] domain.BaseRepository[domain.Author]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewAuthorRepository creates a new AuthorRepository. // NewAuthorRepository creates a new AuthorRepository.
@ -17,11 +20,14 @@ func NewAuthorRepository(db *gorm.DB) domain.AuthorRepository {
return &authorRepository{ return &authorRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Author](db), BaseRepository: NewBaseRepositoryImpl[domain.Author](db),
db: db, db: db,
tracer: otel.Tracer("author.repository"),
} }
} }
// ListByWorkID finds authors by work ID // ListByWorkID finds authors by work ID
func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) { func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var authors []domain.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.author_id = authors.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.author_id = authors.id").
Where("work_authors.work_id = ?", workID). Where("work_authors.work_id = ?", workID).
@ -33,6 +39,8 @@ func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]dom
// GetWithTranslations finds an author by ID and preloads their translations. // GetWithTranslations finds an author by ID and preloads their translations.
func (r *authorRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Author, error) { func (r *authorRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Author, error) {
ctx, span := r.tracer.Start(ctx, "GetWithTranslations")
defer span.End()
var author domain.Author var author domain.Author
if err := r.db.WithContext(ctx).Preload("Translations").First(&author, id).Error; err != nil { if err := r.db.WithContext(ctx).Preload("Translations").First(&author, id).Error; err != nil {
return nil, err return nil, err
@ -42,6 +50,8 @@ func (r *authorRepository) GetWithTranslations(ctx context.Context, id uint) (*d
// ListByBookID finds authors by book ID // ListByBookID finds authors by book ID
func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) { func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) {
ctx, span := r.tracer.Start(ctx, "ListByBookID")
defer span.End()
var authors []domain.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.author_id = authors.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.author_id = authors.id").
Where("book_authors.book_id = ?", bookID). Where("book_authors.book_id = ?", bookID).
@ -53,6 +63,8 @@ func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]dom
// ListByCountryID finds authors by country ID // ListByCountryID finds authors by country ID
func (r *authorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) { func (r *authorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) {
ctx, span := r.tracer.Start(ctx, "ListByCountryID")
defer span.End()
var authors []domain.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&authors).Error; err != nil { if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&authors).Error; err != nil {
return nil, err return nil, err

View File

@ -9,6 +9,8 @@ import (
"tercul/internal/platform/log" "tercul/internal/platform/log"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -24,12 +26,16 @@ var (
// BaseRepositoryImpl provides a default implementation of BaseRepository using GORM // BaseRepositoryImpl provides a default implementation of BaseRepository using GORM
type BaseRepositoryImpl[T any] struct { type BaseRepositoryImpl[T any] struct {
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewBaseRepositoryImpl creates a new BaseRepositoryImpl // NewBaseRepositoryImpl creates a new BaseRepositoryImpl
func NewBaseRepositoryImpl[T any](db *gorm.DB) domain.BaseRepository[T] { func NewBaseRepositoryImpl[T any](db *gorm.DB) domain.BaseRepository[T] {
return &BaseRepositoryImpl[T]{db: db} return &BaseRepositoryImpl[T]{
db: db,
tracer: otel.Tracer("base.repository"),
}
} }
// validateContext ensures context is not nil // validateContext ensures context is not nil
@ -113,6 +119,8 @@ func (r *BaseRepositoryImpl[T]) Create(ctx context.Context, entity *T) error {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "Create")
defer span.End()
if err := r.validateEntity(entity); err != nil { if err := r.validateEntity(entity); err != nil {
return err return err
} }
@ -122,14 +130,11 @@ func (r *BaseRepositoryImpl[T]) Create(ctx context.Context, entity *T) error {
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
log.LogError("Failed to create entity", log.Error(err, "Failed to create entity")
log.F("error", err),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity created successfully", log.Debug(fmt.Sprintf("Entity created successfully in %s", duration))
log.F("duration", duration))
return nil return nil
} }
@ -138,6 +143,8 @@ func (r *BaseRepositoryImpl[T]) CreateInTx(ctx context.Context, tx *gorm.DB, ent
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "CreateInTx")
defer span.End()
if err := r.validateEntity(entity); err != nil { if err := r.validateEntity(entity); err != nil {
return err return err
} }
@ -150,14 +157,11 @@ func (r *BaseRepositoryImpl[T]) CreateInTx(ctx context.Context, tx *gorm.DB, ent
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
log.LogError("Failed to create entity in transaction", log.Error(err, "Failed to create entity in transaction")
log.F("error", err),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity created successfully in transaction", log.Debug(fmt.Sprintf("Entity created successfully in transaction in %s", duration))
log.F("duration", duration))
return nil return nil
} }
@ -166,6 +170,8 @@ func (r *BaseRepositoryImpl[T]) GetByID(ctx context.Context, id uint) (*T, error
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "GetByID")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return nil, err return nil, err
} }
@ -177,21 +183,14 @@ func (r *BaseRepositoryImpl[T]) GetByID(ctx context.Context, id uint) (*T, error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
log.LogDebug("Entity not found", log.Debug(fmt.Sprintf("Entity with id %d not found in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
} }
log.LogError("Failed to get entity by ID", log.Error(err, fmt.Sprintf("Failed to get entity by ID %d", id))
log.F("id", id),
log.F("error", err),
log.F("duration", duration))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity retrieved successfully", log.Debug(fmt.Sprintf("Entity with id %d retrieved successfully in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return &entity, nil return &entity, nil
} }
@ -200,6 +199,8 @@ func (r *BaseRepositoryImpl[T]) GetByIDWithOptions(ctx context.Context, id uint,
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "GetByIDWithOptions")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return nil, err return nil, err
} }
@ -212,21 +213,14 @@ func (r *BaseRepositoryImpl[T]) GetByIDWithOptions(ctx context.Context, id uint,
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
log.LogDebug("Entity not found with options", log.Debug(fmt.Sprintf("Entity with id %d not found with options in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
} }
log.LogError("Failed to get entity by ID with options", log.Error(err, fmt.Sprintf("Failed to get entity by ID %d with options", id))
log.F("id", id),
log.F("error", err),
log.F("duration", duration))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity retrieved successfully with options", log.Debug(fmt.Sprintf("Entity with id %d retrieved successfully with options in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return &entity, nil return &entity, nil
} }
@ -235,6 +229,8 @@ func (r *BaseRepositoryImpl[T]) Update(ctx context.Context, entity *T) error {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "Update")
defer span.End()
if err := r.validateEntity(entity); err != nil { if err := r.validateEntity(entity); err != nil {
return err return err
} }
@ -244,14 +240,11 @@ func (r *BaseRepositoryImpl[T]) Update(ctx context.Context, entity *T) error {
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
log.LogError("Failed to update entity", log.Error(err, "Failed to update entity")
log.F("error", err),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity updated successfully", log.Debug(fmt.Sprintf("Entity updated successfully in %s", duration))
log.F("duration", duration))
return nil return nil
} }
@ -260,6 +253,8 @@ func (r *BaseRepositoryImpl[T]) UpdateInTx(ctx context.Context, tx *gorm.DB, ent
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "UpdateInTx")
defer span.End()
if err := r.validateEntity(entity); err != nil { if err := r.validateEntity(entity); err != nil {
return err return err
} }
@ -272,14 +267,11 @@ func (r *BaseRepositoryImpl[T]) UpdateInTx(ctx context.Context, tx *gorm.DB, ent
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
log.LogError("Failed to update entity in transaction", log.Error(err, "Failed to update entity in transaction")
log.F("error", err),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
log.LogDebug("Entity updated successfully in transaction", log.Debug(fmt.Sprintf("Entity updated successfully in transaction in %s", duration))
log.F("duration", duration))
return nil return nil
} }
@ -288,6 +280,8 @@ func (r *BaseRepositoryImpl[T]) Delete(ctx context.Context, id uint) error {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "Delete")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return err return err
} }
@ -298,24 +292,16 @@ func (r *BaseRepositoryImpl[T]) Delete(ctx context.Context, id uint) error {
duration := time.Since(start) duration := time.Since(start)
if result.Error != nil { if result.Error != nil {
log.LogError("Failed to delete entity", log.Error(result.Error, fmt.Sprintf("Failed to delete entity with id %d", id))
log.F("id", id),
log.F("error", result.Error),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error) return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error)
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
log.LogDebug("No entity found to delete", log.Debug(fmt.Sprintf("No entity with id %d found to delete in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return ErrEntityNotFound return ErrEntityNotFound
} }
log.LogDebug("Entity deleted successfully", log.Debug(fmt.Sprintf("Entity with id %d deleted successfully in %s", id, duration))
log.F("id", id),
log.F("rowsAffected", result.RowsAffected),
log.F("duration", duration))
return nil return nil
} }
@ -324,6 +310,8 @@ func (r *BaseRepositoryImpl[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "DeleteInTx")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return err return err
} }
@ -337,24 +325,16 @@ func (r *BaseRepositoryImpl[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id
duration := time.Since(start) duration := time.Since(start)
if result.Error != nil { if result.Error != nil {
log.LogError("Failed to delete entity in transaction", log.Error(result.Error, fmt.Sprintf("Failed to delete entity with id %d in transaction", id))
log.F("id", id),
log.F("error", result.Error),
log.F("duration", duration))
return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error) return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error)
} }
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
log.LogDebug("No entity found to delete in transaction", log.Debug(fmt.Sprintf("No entity with id %d found to delete in transaction in %s", id, duration))
log.F("id", id),
log.F("duration", duration))
return ErrEntityNotFound return ErrEntityNotFound
} }
log.LogDebug("Entity deleted successfully in transaction", log.Debug(fmt.Sprintf("Entity with id %d deleted successfully in transaction in %s", id, duration))
log.F("id", id),
log.F("rowsAffected", result.RowsAffected),
log.F("duration", duration))
return nil return nil
} }
@ -363,6 +343,8 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "List")
defer span.End()
page, pageSize, err := r.validatePagination(page, pageSize) page, pageSize, err := r.validatePagination(page, pageSize)
if err != nil { if err != nil {
@ -375,9 +357,7 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
// Get total count // Get total count
if err := r.db.WithContext(ctx).Model(new(T)).Count(&totalCount).Error; err != nil { if err := r.db.WithContext(ctx).Model(new(T)).Count(&totalCount).Error; err != nil {
log.LogError("Failed to count entities", log.Error(err, "Failed to count entities")
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
@ -386,11 +366,7 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
// Get paginated data // Get paginated data
if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&entities).Error; err != nil { if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&entities).Error; err != nil {
log.LogError("Failed to get paginated entities", log.Error(err, "Failed to get paginated entities")
log.F("page", page),
log.F("pageSize", pageSize),
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
@ -405,14 +381,7 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
hasNext := page < totalPages hasNext := page < totalPages
hasPrev := page > 1 hasPrev := page > 1
log.LogDebug("Paginated entities retrieved successfully", log.Debug(fmt.Sprintf("Paginated entities retrieved successfully in %s", duration))
log.F("page", page),
log.F("pageSize", pageSize),
log.F("totalCount", totalCount),
log.F("totalPages", totalPages),
log.F("hasNext", hasNext),
log.F("hasPrev", hasPrev),
log.F("duration", duration))
return &domain.PaginatedResult[T]{ return &domain.PaginatedResult[T]{
Items: entities, Items: entities,
@ -430,22 +399,20 @@ func (r *BaseRepositoryImpl[T]) ListWithOptions(ctx context.Context, options *do
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "ListWithOptions")
defer span.End()
start := time.Now() start := time.Now()
var entities []T var entities []T
query := r.buildQuery(r.db.WithContext(ctx), options) query := r.buildQuery(r.db.WithContext(ctx), options)
if err := query.Find(&entities).Error; err != nil { if err := query.Find(&entities).Error; err != nil {
log.LogError("Failed to get entities with options", log.Error(err, "Failed to get entities with options")
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("Entities retrieved successfully with options", log.Debug(fmt.Sprintf("Entities retrieved successfully with options in %s", duration))
log.F("count", len(entities)),
log.F("duration", duration))
return entities, nil return entities, nil
} }
@ -455,20 +422,18 @@ func (r *BaseRepositoryImpl[T]) ListAll(ctx context.Context) ([]T, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "ListAll")
defer span.End()
start := time.Now() start := time.Now()
var entities []T var entities []T
if err := r.db.WithContext(ctx).Find(&entities).Error; err != nil { if err := r.db.WithContext(ctx).Find(&entities).Error; err != nil {
log.LogError("Failed to get all entities", log.Error(err, "Failed to get all entities")
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("All entities retrieved successfully", log.Debug(fmt.Sprintf("All entities retrieved successfully in %s", duration))
log.F("count", len(entities)),
log.F("duration", duration))
return entities, nil return entities, nil
} }
@ -478,20 +443,18 @@ func (r *BaseRepositoryImpl[T]) Count(ctx context.Context) (int64, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return 0, err return 0, err
} }
ctx, span := r.tracer.Start(ctx, "Count")
defer span.End()
start := time.Now() start := time.Now()
var count int64 var count int64
if err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error; err != nil { if err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error; err != nil {
log.LogError("Failed to count entities", log.Error(err, "Failed to count entities")
log.F("error", err),
log.F("duration", time.Since(start)))
return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("Entity count retrieved successfully", log.Debug(fmt.Sprintf("Entity count retrieved successfully in %s", duration))
log.F("count", count),
log.F("duration", duration))
return count, nil return count, nil
} }
@ -501,22 +464,20 @@ func (r *BaseRepositoryImpl[T]) CountWithOptions(ctx context.Context, options *d
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return 0, err return 0, err
} }
ctx, span := r.tracer.Start(ctx, "CountWithOptions")
defer span.End()
start := time.Now() start := time.Now()
var count int64 var count int64
query := r.buildQuery(r.db.WithContext(ctx), options) query := r.buildQuery(r.db.WithContext(ctx), options)
if err := query.Model(new(T)).Count(&count).Error; err != nil { if err := query.Model(new(T)).Count(&count).Error; err != nil {
log.LogError("Failed to count entities with options", log.Error(err, "Failed to count entities with options")
log.F("error", err),
log.F("duration", time.Since(start)))
return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("Entity count retrieved successfully with options", log.Debug(fmt.Sprintf("Entity count retrieved successfully with options in %s", duration))
log.F("count", count),
log.F("duration", duration))
return count, nil return count, nil
} }
@ -526,6 +487,8 @@ func (r *BaseRepositoryImpl[T]) FindWithPreload(ctx context.Context, preloads []
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "FindWithPreload")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return nil, err return nil, err
} }
@ -540,25 +503,15 @@ func (r *BaseRepositoryImpl[T]) FindWithPreload(ctx context.Context, preloads []
if err := query.First(&entity, id).Error; err != nil { if err := query.First(&entity, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
log.LogDebug("Entity not found with preloads", log.Debug(fmt.Sprintf("Entity with id %d not found with preloads in %s", id, time.Since(start)))
log.F("id", id),
log.F("preloads", preloads),
log.F("duration", time.Since(start)))
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
} }
log.LogError("Failed to get entity with preloads", log.Error(err, fmt.Sprintf("Failed to get entity with id %d with preloads", id))
log.F("id", id),
log.F("preloads", preloads),
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("Entity retrieved successfully with preloads", log.Debug(fmt.Sprintf("Entity with id %d retrieved successfully with preloads in %s", id, duration))
log.F("id", id),
log.F("preloads", preloads),
log.F("duration", duration))
return &entity, nil return &entity, nil
} }
@ -568,6 +521,8 @@ func (r *BaseRepositoryImpl[T]) GetAllForSync(ctx context.Context, batchSize, of
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "GetAllForSync")
defer span.End()
if batchSize <= 0 { if batchSize <= 0 {
batchSize = config.Cfg.BatchSize batchSize = config.Cfg.BatchSize
@ -583,20 +538,12 @@ func (r *BaseRepositoryImpl[T]) GetAllForSync(ctx context.Context, batchSize, of
start := time.Now() start := time.Now()
var entities []T var entities []T
if err := r.db.WithContext(ctx).Offset(offset).Limit(batchSize).Find(&entities).Error; err != nil { if err := r.db.WithContext(ctx).Offset(offset).Limit(batchSize).Find(&entities).Error; err != nil {
log.LogError("Failed to get entities for sync", log.Error(err, "Failed to get entities for sync")
log.F("batchSize", batchSize),
log.F("offset", offset),
log.F("error", err),
log.F("duration", time.Since(start)))
return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
log.LogDebug("Entities retrieved successfully for sync", log.Debug(fmt.Sprintf("Entities retrieved successfully for sync in %s", duration))
log.F("batchSize", batchSize),
log.F("offset", offset),
log.F("count", len(entities)),
log.F("duration", duration))
return entities, nil return entities, nil
} }
@ -606,6 +553,8 @@ func (r *BaseRepositoryImpl[T]) Exists(ctx context.Context, id uint) (bool, erro
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return false, err return false, err
} }
ctx, span := r.tracer.Start(ctx, "Exists")
defer span.End()
if err := r.validateID(id); err != nil { if err := r.validateID(id); err != nil {
return false, err return false, err
} }
@ -613,20 +562,14 @@ func (r *BaseRepositoryImpl[T]) Exists(ctx context.Context, id uint) (bool, erro
start := time.Now() start := time.Now()
var count int64 var count int64
if err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error; err != nil { if err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error; err != nil {
log.LogError("Failed to check entity existence", log.Error(err, fmt.Sprintf("Failed to check entity existence for id %d", id))
log.F("id", id),
log.F("error", err),
log.F("duration", time.Since(start)))
return false, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) return false, fmt.Errorf("%w: %v", ErrDatabaseOperation, err)
} }
duration := time.Since(start) duration := time.Since(start)
exists := count > 0 exists := count > 0
log.LogDebug("Entity existence checked", log.Debug(fmt.Sprintf("Entity existence checked for id %d in %s", id, duration))
log.F("id", id),
log.F("exists", exists),
log.F("duration", duration))
return exists, nil return exists, nil
} }
@ -636,15 +579,16 @@ func (r *BaseRepositoryImpl[T]) BeginTx(ctx context.Context) (*gorm.DB, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
ctx, span := r.tracer.Start(ctx, "BeginTx")
defer span.End()
tx := r.db.WithContext(ctx).Begin() tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil { if tx.Error != nil {
log.LogError("Failed to begin transaction", log.Error(tx.Error, "Failed to begin transaction")
log.F("error", tx.Error))
return nil, fmt.Errorf("%w: %v", ErrTransactionFailed, tx.Error) return nil, fmt.Errorf("%w: %v", ErrTransactionFailed, tx.Error)
} }
log.LogDebug("Transaction started successfully") log.Debug("Transaction started successfully")
return tx, nil return tx, nil
} }
@ -653,6 +597,8 @@ func (r *BaseRepositoryImpl[T]) WithTx(ctx context.Context, fn func(tx *gorm.DB)
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return err return err
} }
ctx, span := r.tracer.Start(ctx, "WithTx")
defer span.End()
tx, err := r.BeginTx(ctx) tx, err := r.BeginTx(ctx)
if err != nil { if err != nil {
@ -662,29 +608,24 @@ func (r *BaseRepositoryImpl[T]) WithTx(ctx context.Context, fn func(tx *gorm.DB)
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
tx.Rollback() tx.Rollback()
log.LogError("Transaction panic recovered", log.Error(fmt.Errorf("panic recovered: %v", r), "Transaction panic recovered")
log.F("panic", r))
} }
}() }()
if err := fn(tx); err != nil { if err := fn(tx); err != nil {
if rbErr := tx.Rollback().Error; rbErr != nil { if rbErr := tx.Rollback().Error; rbErr != nil {
log.LogError("Failed to rollback transaction", log.Error(rbErr, fmt.Sprintf("Failed to rollback transaction after error: %v", err))
log.F("originalError", err),
log.F("rollbackError", rbErr))
return fmt.Errorf("transaction failed and rollback failed: %v (rollback: %v)", err, rbErr) return fmt.Errorf("transaction failed and rollback failed: %v (rollback: %v)", err, rbErr)
} }
log.LogDebug("Transaction rolled back due to error", log.Debug(fmt.Sprintf("Transaction rolled back due to error: %v", err))
log.F("error", err))
return err return err
} }
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
log.LogError("Failed to commit transaction", log.Error(err, "Failed to commit transaction")
log.F("error", err))
return fmt.Errorf("%w: %v", ErrTransactionFailed, err) return fmt.Errorf("%w: %v", ErrTransactionFailed, err)
} }
log.LogDebug("Transaction committed successfully") log.Debug("Transaction committed successfully")
return nil return nil
} }

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type bookRepository struct { type bookRepository struct {
domain.BaseRepository[domain.Book] domain.BaseRepository[domain.Book]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewBookRepository creates a new BookRepository. // NewBookRepository creates a new BookRepository.
@ -18,11 +21,14 @@ func NewBookRepository(db *gorm.DB) domain.BookRepository {
return &bookRepository{ return &bookRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Book](db), BaseRepository: NewBaseRepositoryImpl[domain.Book](db),
db: db, db: db,
tracer: otel.Tracer("book.repository"),
} }
} }
// ListByAuthorID finds books by author ID // ListByAuthorID finds books by author ID
func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]domain.Book, error) { func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]domain.Book, error) {
ctx, span := r.tracer.Start(ctx, "ListByAuthorID")
defer span.End()
var books []domain.Book var books []domain.Book
if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.book_id = books.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.book_id = books.id").
Where("book_authors.author_id = ?", authorID). Where("book_authors.author_id = ?", authorID).
@ -34,6 +40,8 @@ func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]d
// ListByPublisherID finds books by publisher ID // ListByPublisherID finds books by publisher ID
func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint) ([]domain.Book, error) { func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint) ([]domain.Book, error) {
ctx, span := r.tracer.Start(ctx, "ListByPublisherID")
defer span.End()
var books []domain.Book var books []domain.Book
if err := r.db.WithContext(ctx).Where("publisher_id = ?", publisherID).Find(&books).Error; err != nil { if err := r.db.WithContext(ctx).Where("publisher_id = ?", publisherID).Find(&books).Error; err != nil {
return nil, err return nil, err
@ -43,6 +51,8 @@ func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint
// ListByWorkID finds books by work ID // ListByWorkID finds books by work ID
func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Book, error) { func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Book, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var books []domain.Book var books []domain.Book
if err := r.db.WithContext(ctx).Joins("JOIN book_works ON book_works.book_id = books.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_works ON book_works.book_id = books.id").
Where("book_works.work_id = ?", workID). Where("book_works.work_id = ?", workID).
@ -54,6 +64,8 @@ func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]domai
// FindByISBN finds a book by ISBN // FindByISBN finds a book by ISBN
func (r *bookRepository) FindByISBN(ctx context.Context, isbn string) (*domain.Book, error) { func (r *bookRepository) FindByISBN(ctx context.Context, isbn string) (*domain.Book, error) {
ctx, span := r.tracer.Start(ctx, "FindByISBN")
defer span.End()
var book domain.Book var book domain.Book
if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&book).Error; err != nil { if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&book).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type bookmarkRepository struct { type bookmarkRepository struct {
domain.BaseRepository[domain.Bookmark] domain.BaseRepository[domain.Bookmark]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewBookmarkRepository creates a new BookmarkRepository. // NewBookmarkRepository creates a new BookmarkRepository.
@ -17,11 +20,14 @@ func NewBookmarkRepository(db *gorm.DB) domain.BookmarkRepository {
return &bookmarkRepository{ return &bookmarkRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Bookmark](db), BaseRepository: NewBaseRepositoryImpl[domain.Bookmark](db),
db: db, db: db,
tracer: otel.Tracer("bookmark.repository"),
} }
} }
// ListByUserID finds bookmarks by user ID // ListByUserID finds bookmarks by user ID
func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) { func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var bookmarks []domain.Bookmark var bookmarks []domain.Bookmark
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]d
// ListByWorkID finds bookmarks by work ID // ListByWorkID finds bookmarks by work ID
func (r *bookmarkRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) { func (r *bookmarkRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var bookmarks []domain.Bookmark var bookmarks []domain.Bookmark
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&bookmarks).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&bookmarks).Error; err != nil {
return nil, err return nil, err

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type categoryRepository struct { type categoryRepository struct {
domain.BaseRepository[domain.Category] domain.BaseRepository[domain.Category]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewCategoryRepository creates a new CategoryRepository. // NewCategoryRepository creates a new CategoryRepository.
@ -18,11 +21,14 @@ func NewCategoryRepository(db *gorm.DB) domain.CategoryRepository {
return &categoryRepository{ return &categoryRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Category](db), BaseRepository: NewBaseRepositoryImpl[domain.Category](db),
db: db, db: db,
tracer: otel.Tracer("category.repository"),
} }
} }
// FindByName finds a category by name // FindByName finds a category by name
func (r *categoryRepository) FindByName(ctx context.Context, name string) (*domain.Category, error) { func (r *categoryRepository) FindByName(ctx context.Context, name string) (*domain.Category, error) {
ctx, span := r.tracer.Start(ctx, "FindByName")
defer span.End()
var category domain.Category var category domain.Category
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&category).Error; err != nil { if err := r.db.WithContext(ctx).Where("name = ?", name).First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -35,6 +41,8 @@ func (r *categoryRepository) FindByName(ctx context.Context, name string) (*doma
// ListByWorkID finds categories by work ID // ListByWorkID finds categories by work ID
func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) { func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var categories []domain.Category var categories []domain.Category
if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.category_id = categories.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.category_id = categories.id").
Where("work_categories.work_id = ?", workID). Where("work_categories.work_id = ?", workID).
@ -46,6 +54,8 @@ func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]d
// ListByParentID finds categories by parent ID // ListByParentID finds categories by parent ID
func (r *categoryRepository) ListByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) { func (r *categoryRepository) ListByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) {
ctx, span := r.tracer.Start(ctx, "ListByParentID")
defer span.End()
var categories []domain.Category var categories []domain.Category
if parentID == nil { if parentID == nil {
if err := r.db.WithContext(ctx).Where("parent_id IS NULL").Find(&categories).Error; err != nil { if err := r.db.WithContext(ctx).Where("parent_id IS NULL").Find(&categories).Error; err != nil {

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type collectionRepository struct { type collectionRepository struct {
domain.BaseRepository[domain.Collection] domain.BaseRepository[domain.Collection]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewCollectionRepository creates a new CollectionRepository. // NewCollectionRepository creates a new CollectionRepository.
@ -17,11 +20,14 @@ func NewCollectionRepository(db *gorm.DB) domain.CollectionRepository {
return &collectionRepository{ return &collectionRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Collection](db), BaseRepository: NewBaseRepositoryImpl[domain.Collection](db),
db: db, db: db,
tracer: otel.Tracer("collection.repository"),
} }
} }
// ListByUserID finds collections by user ID // ListByUserID finds collections by user ID
func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) { func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var collections []domain.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&collections).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&collections).Error; err != nil {
return nil, err return nil, err
@ -31,16 +37,22 @@ func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([
// AddWorkToCollection adds a work to a collection // AddWorkToCollection adds a work to a collection
func (r *collectionRepository) AddWorkToCollection(ctx context.Context, collectionID uint, workID uint) error { func (r *collectionRepository) AddWorkToCollection(ctx context.Context, collectionID uint, workID uint) error {
ctx, span := r.tracer.Start(ctx, "AddWorkToCollection")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO collection_works (collection_id, work_id) VALUES (?, ?) ON CONFLICT DO NOTHING", collectionID, workID).Error return r.db.WithContext(ctx).Exec("INSERT INTO collection_works (collection_id, work_id) VALUES (?, ?) ON CONFLICT DO NOTHING", collectionID, workID).Error
} }
// RemoveWorkFromCollection removes a work from a collection // RemoveWorkFromCollection removes a work from a collection
func (r *collectionRepository) RemoveWorkFromCollection(ctx context.Context, collectionID uint, workID uint) error { func (r *collectionRepository) RemoveWorkFromCollection(ctx context.Context, collectionID uint, workID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveWorkFromCollection")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM collection_works WHERE collection_id = ? AND work_id = ?", collectionID, workID).Error return r.db.WithContext(ctx).Exec("DELETE FROM collection_works WHERE collection_id = ? AND work_id = ?", collectionID, workID).Error
} }
// ListPublic finds public collections // ListPublic finds public collections
func (r *collectionRepository) ListPublic(ctx context.Context) ([]domain.Collection, error) { func (r *collectionRepository) ListPublic(ctx context.Context) ([]domain.Collection, error) {
ctx, span := r.tracer.Start(ctx, "ListPublic")
defer span.End()
var collections []domain.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&collections).Error; err != nil { if err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&collections).Error; err != nil {
return nil, err return nil, err
@ -50,6 +62,8 @@ func (r *collectionRepository) ListPublic(ctx context.Context) ([]domain.Collect
// ListByWorkID finds collections by work ID // ListByWorkID finds collections by work ID
func (r *collectionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) { func (r *collectionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var collections []domain.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.collection_id = collections.id"). if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.collection_id = collections.id").
Where("collection_works.work_id = ?", workID). Where("collection_works.work_id = ?", workID).

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type commentRepository struct { type commentRepository struct {
domain.BaseRepository[domain.Comment] domain.BaseRepository[domain.Comment]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewCommentRepository creates a new CommentRepository. // NewCommentRepository creates a new CommentRepository.
@ -17,11 +20,14 @@ func NewCommentRepository(db *gorm.DB) domain.CommentRepository {
return &commentRepository{ return &commentRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Comment](db), BaseRepository: NewBaseRepositoryImpl[domain.Comment](db),
db: db, db: db,
tracer: otel.Tracer("comment.repository"),
} }
} }
// ListByUserID finds comments by user ID // ListByUserID finds comments by user ID
func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) { func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var comments []domain.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&comments).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]do
// ListByWorkID finds comments by work ID // ListByWorkID finds comments by work ID
func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) { func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var comments []domain.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&comments).Error; err != nil {
return nil, err return nil, err
@ -40,6 +48,8 @@ func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]do
// ListByTranslationID finds comments by translation ID // ListByTranslationID finds comments by translation ID
func (r *commentRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) { func (r *commentRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) {
ctx, span := r.tracer.Start(ctx, "ListByTranslationID")
defer span.End()
var comments []domain.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&comments).Error; err != nil {
return nil, err return nil, err
@ -49,6 +59,8 @@ func (r *commentRepository) ListByTranslationID(ctx context.Context, translation
// ListByParentID finds comments by parent ID // ListByParentID finds comments by parent ID
func (r *commentRepository) ListByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) { func (r *commentRepository) ListByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) {
ctx, span := r.tracer.Start(ctx, "ListByParentID")
defer span.End()
var comments []domain.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("parent_id = ?", parentID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("parent_id = ?", parentID).Find(&comments).Error; err != nil {
return nil, err return nil, err

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type contributionRepository struct { type contributionRepository struct {
domain.BaseRepository[domain.Contribution] domain.BaseRepository[domain.Contribution]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewContributionRepository creates a new ContributionRepository. // NewContributionRepository creates a new ContributionRepository.
@ -17,11 +20,14 @@ func NewContributionRepository(db *gorm.DB) domain.ContributionRepository {
return &contributionRepository{ return &contributionRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Contribution](db), BaseRepository: NewBaseRepositoryImpl[domain.Contribution](db),
db: db, db: db,
tracer: otel.Tracer("contribution.repository"),
} }
} }
// ListByUserID finds contributions by user ID // ListByUserID finds contributions by user ID
func (r *contributionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Contribution, error) { func (r *contributionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Contribution, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var contributions []domain.Contribution var contributions []domain.Contribution
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&contributions).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&contributions).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *contributionRepository) ListByUserID(ctx context.Context, userID uint)
// ListByReviewerID finds contributions by reviewer ID // ListByReviewerID finds contributions by reviewer ID
func (r *contributionRepository) ListByReviewerID(ctx context.Context, reviewerID uint) ([]domain.Contribution, error) { func (r *contributionRepository) ListByReviewerID(ctx context.Context, reviewerID uint) ([]domain.Contribution, error) {
ctx, span := r.tracer.Start(ctx, "ListByReviewerID")
defer span.End()
var contributions []domain.Contribution var contributions []domain.Contribution
if err := r.db.WithContext(ctx).Where("reviewer_id = ?", reviewerID).Find(&contributions).Error; err != nil { if err := r.db.WithContext(ctx).Where("reviewer_id = ?", reviewerID).Find(&contributions).Error; err != nil {
return nil, err return nil, err
@ -40,6 +48,8 @@ func (r *contributionRepository) ListByReviewerID(ctx context.Context, reviewerI
// ListByWorkID finds contributions by work ID // ListByWorkID finds contributions by work ID
func (r *contributionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Contribution, error) { func (r *contributionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Contribution, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var contributions []domain.Contribution var contributions []domain.Contribution
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&contributions).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&contributions).Error; err != nil {
return nil, err return nil, err
@ -49,6 +59,8 @@ func (r *contributionRepository) ListByWorkID(ctx context.Context, workID uint)
// ListByTranslationID finds contributions by translation ID // ListByTranslationID finds contributions by translation ID
func (r *contributionRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Contribution, error) { func (r *contributionRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Contribution, error) {
ctx, span := r.tracer.Start(ctx, "ListByTranslationID")
defer span.End()
var contributions []domain.Contribution var contributions []domain.Contribution
if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&contributions).Error; err != nil { if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&contributions).Error; err != nil {
return nil, err return nil, err
@ -58,6 +70,8 @@ func (r *contributionRepository) ListByTranslationID(ctx context.Context, transl
// ListByStatus finds contributions by status // ListByStatus finds contributions by status
func (r *contributionRepository) ListByStatus(ctx context.Context, status string) ([]domain.Contribution, error) { func (r *contributionRepository) ListByStatus(ctx context.Context, status string) ([]domain.Contribution, error) {
ctx, span := r.tracer.Start(ctx, "ListByStatus")
defer span.End()
var contributions []domain.Contribution var contributions []domain.Contribution
if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&contributions).Error; err != nil { if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&contributions).Error; err != nil {
return nil, err return nil, err

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type copyrightClaimRepository struct { type copyrightClaimRepository struct {
domain.BaseRepository[domain.CopyrightClaim] domain.BaseRepository[domain.CopyrightClaim]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewCopyrightClaimRepository creates a new CopyrightClaimRepository. // NewCopyrightClaimRepository creates a new CopyrightClaimRepository.
@ -17,11 +20,14 @@ func NewCopyrightClaimRepository(db *gorm.DB) domain.CopyrightClaimRepository {
return &copyrightClaimRepository{ return &copyrightClaimRepository{
BaseRepository: NewBaseRepositoryImpl[domain.CopyrightClaim](db), BaseRepository: NewBaseRepositoryImpl[domain.CopyrightClaim](db),
db: db, db: db,
tracer: otel.Tracer("copyright_claim.repository"),
} }
} }
// ListByWorkID finds claims by work ID // ListByWorkID finds claims by work ID
func (r *copyrightClaimRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.CopyrightClaim, error) { func (r *copyrightClaimRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.CopyrightClaim, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var claims []domain.CopyrightClaim var claims []domain.CopyrightClaim
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&claims).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&claims).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *copyrightClaimRepository) ListByWorkID(ctx context.Context, workID uint
// ListByUserID finds claims by user ID // ListByUserID finds claims by user ID
func (r *copyrightClaimRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.CopyrightClaim, error) { func (r *copyrightClaimRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.CopyrightClaim, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var claims []domain.CopyrightClaim var claims []domain.CopyrightClaim
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&claims).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&claims).Error; err != nil {
return nil, err return nil, err

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type copyrightRepository struct { type copyrightRepository struct {
domain.BaseRepository[domain.Copyright] domain.BaseRepository[domain.Copyright]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewCopyrightRepository creates a new CopyrightRepository. // NewCopyrightRepository creates a new CopyrightRepository.
@ -18,16 +21,21 @@ func NewCopyrightRepository(db *gorm.DB) domain.CopyrightRepository {
return &copyrightRepository{ return &copyrightRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Copyright](db), BaseRepository: NewBaseRepositoryImpl[domain.Copyright](db),
db: db, db: db,
tracer: otel.Tracer("copyright.repository"),
} }
} }
// AddTranslation adds a translation to a copyright // AddTranslation adds a translation to a copyright
func (r *copyrightRepository) AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) error { func (r *copyrightRepository) AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) error {
ctx, span := r.tracer.Start(ctx, "AddTranslation")
defer span.End()
return r.db.WithContext(ctx).Create(translation).Error return r.db.WithContext(ctx).Create(translation).Error
} }
// GetTranslations gets all translations for a copyright // GetTranslations gets all translations for a copyright
func (r *copyrightRepository) GetTranslations(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) { func (r *copyrightRepository) GetTranslations(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) {
ctx, span := r.tracer.Start(ctx, "GetTranslations")
defer span.End()
var translations []domain.CopyrightTranslation var translations []domain.CopyrightTranslation
err := r.db.WithContext(ctx).Where("copyright_id = ?", copyrightID).Find(&translations).Error err := r.db.WithContext(ctx).Where("copyright_id = ?", copyrightID).Find(&translations).Error
return translations, err return translations, err
@ -35,6 +43,8 @@ func (r *copyrightRepository) GetTranslations(ctx context.Context, copyrightID u
// GetTranslationByLanguage gets a specific translation by language code // GetTranslationByLanguage gets a specific translation by language code
func (r *copyrightRepository) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) { func (r *copyrightRepository) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) {
ctx, span := r.tracer.Start(ctx, "GetTranslationByLanguage")
defer span.End()
var translation domain.CopyrightTranslation var translation domain.CopyrightTranslation
err := r.db.WithContext(ctx).Where("copyright_id = ? AND language_code = ?", copyrightID, languageCode).First(&translation).Error err := r.db.WithContext(ctx).Where("copyright_id = ? AND language_code = ?", copyrightID, languageCode).First(&translation).Error
if err != nil { if err != nil {
@ -47,41 +57,61 @@ func (r *copyrightRepository) GetTranslationByLanguage(ctx context.Context, copy
} }
func (r *copyrightRepository) AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error { func (r *copyrightRepository) AddCopyrightToWork(ctx context.Context, workID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "AddCopyrightToWork")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO work_copyrights (work_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", workID, copyrightID).Error return r.db.WithContext(ctx).Exec("INSERT INTO work_copyrights (work_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", workID, copyrightID).Error
} }
func (r *copyrightRepository) RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error { func (r *copyrightRepository) RemoveCopyrightFromWork(ctx context.Context, workID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveCopyrightFromWork")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM work_copyrights WHERE work_id = ? AND copyright_id = ?", workID, copyrightID).Error return r.db.WithContext(ctx).Exec("DELETE FROM work_copyrights WHERE work_id = ? AND copyright_id = ?", workID, copyrightID).Error
} }
func (r *copyrightRepository) AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error { func (r *copyrightRepository) AddCopyrightToAuthor(ctx context.Context, authorID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "AddCopyrightToAuthor")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO author_copyrights (author_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", authorID, copyrightID).Error return r.db.WithContext(ctx).Exec("INSERT INTO author_copyrights (author_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", authorID, copyrightID).Error
} }
func (r *copyrightRepository) RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error { func (r *copyrightRepository) RemoveCopyrightFromAuthor(ctx context.Context, authorID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveCopyrightFromAuthor")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM author_copyrights WHERE author_id = ? AND copyright_id = ?", authorID, copyrightID).Error return r.db.WithContext(ctx).Exec("DELETE FROM author_copyrights WHERE author_id = ? AND copyright_id = ?", authorID, copyrightID).Error
} }
func (r *copyrightRepository) AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error { func (r *copyrightRepository) AddCopyrightToBook(ctx context.Context, bookID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "AddCopyrightToBook")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO book_copyrights (book_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", bookID, copyrightID).Error return r.db.WithContext(ctx).Exec("INSERT INTO book_copyrights (book_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", bookID, copyrightID).Error
} }
func (r *copyrightRepository) RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error { func (r *copyrightRepository) RemoveCopyrightFromBook(ctx context.Context, bookID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveCopyrightFromBook")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM book_copyrights WHERE book_id = ? AND copyright_id = ?", bookID, copyrightID).Error return r.db.WithContext(ctx).Exec("DELETE FROM book_copyrights WHERE book_id = ? AND copyright_id = ?", bookID, copyrightID).Error
} }
func (r *copyrightRepository) AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { func (r *copyrightRepository) AddCopyrightToPublisher(ctx context.Context, publisherID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "AddCopyrightToPublisher")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO publisher_copyrights (publisher_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", publisherID, copyrightID).Error return r.db.WithContext(ctx).Exec("INSERT INTO publisher_copyrights (publisher_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", publisherID, copyrightID).Error
} }
func (r *copyrightRepository) RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error { func (r *copyrightRepository) RemoveCopyrightFromPublisher(ctx context.Context, publisherID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveCopyrightFromPublisher")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM publisher_copyrights WHERE publisher_id = ? AND copyright_id = ?", publisherID, copyrightID).Error return r.db.WithContext(ctx).Exec("DELETE FROM publisher_copyrights WHERE publisher_id = ? AND copyright_id = ?", publisherID, copyrightID).Error
} }
func (r *copyrightRepository) AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error { func (r *copyrightRepository) AddCopyrightToSource(ctx context.Context, sourceID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "AddCopyrightToSource")
defer span.End()
return r.db.WithContext(ctx).Exec("INSERT INTO source_copyrights (source_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", sourceID, copyrightID).Error return r.db.WithContext(ctx).Exec("INSERT INTO source_copyrights (source_id, copyright_id) VALUES (?, ?) ON CONFLICT DO NOTHING", sourceID, copyrightID).Error
} }
func (r *copyrightRepository) RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error { func (r *copyrightRepository) RemoveCopyrightFromSource(ctx context.Context, sourceID uint, copyrightID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveCopyrightFromSource")
defer span.End()
return r.db.WithContext(ctx).Exec("DELETE FROM source_copyrights WHERE source_id = ? AND copyright_id = ?", sourceID, copyrightID).Error return r.db.WithContext(ctx).Exec("DELETE FROM source_copyrights WHERE source_id = ? AND copyright_id = ?", sourceID, copyrightID).Error
} }

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type edgeRepository struct { type edgeRepository struct {
domain.BaseRepository[domain.Edge] domain.BaseRepository[domain.Edge]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewEdgeRepository creates a new EdgeRepository. // NewEdgeRepository creates a new EdgeRepository.
@ -17,11 +20,14 @@ func NewEdgeRepository(db *gorm.DB) domain.EdgeRepository {
return &edgeRepository{ return &edgeRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Edge](db), BaseRepository: NewBaseRepositoryImpl[domain.Edge](db),
db: db, db: db,
tracer: otel.Tracer("edge.repository"),
} }
} }
// ListBySource finds edges by source table and ID // ListBySource finds edges by source table and ID
func (r *edgeRepository) ListBySource(ctx context.Context, sourceTable string, sourceID uint) ([]domain.Edge, error) { func (r *edgeRepository) ListBySource(ctx context.Context, sourceTable string, sourceID uint) ([]domain.Edge, error) {
ctx, span := r.tracer.Start(ctx, "ListBySource")
defer span.End()
var edges []domain.Edge var edges []domain.Edge
if err := r.db.WithContext(ctx).Where("source_table = ? AND source_id = ?", sourceTable, sourceID).Find(&edges).Error; err != nil { if err := r.db.WithContext(ctx).Where("source_table = ? AND source_id = ?", sourceTable, sourceID).Find(&edges).Error; err != nil {
return nil, err return nil, err

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type editionRepository struct { type editionRepository struct {
domain.BaseRepository[domain.Edition] domain.BaseRepository[domain.Edition]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewEditionRepository creates a new EditionRepository. // NewEditionRepository creates a new EditionRepository.
@ -18,11 +21,14 @@ func NewEditionRepository(db *gorm.DB) domain.EditionRepository {
return &editionRepository{ return &editionRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Edition](db), BaseRepository: NewBaseRepositoryImpl[domain.Edition](db),
db: db, db: db,
tracer: otel.Tracer("edition.repository"),
} }
} }
// ListByBookID finds editions by book ID // ListByBookID finds editions by book ID
func (r *editionRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Edition, error) { func (r *editionRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Edition, error) {
ctx, span := r.tracer.Start(ctx, "ListByBookID")
defer span.End()
var editions []domain.Edition var editions []domain.Edition
if err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Find(&editions).Error; err != nil { if err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Find(&editions).Error; err != nil {
return nil, err return nil, err
@ -32,6 +38,8 @@ func (r *editionRepository) ListByBookID(ctx context.Context, bookID uint) ([]do
// FindByISBN finds an edition by ISBN // FindByISBN finds an edition by ISBN
func (r *editionRepository) FindByISBN(ctx context.Context, isbn string) (*domain.Edition, error) { func (r *editionRepository) FindByISBN(ctx context.Context, isbn string) (*domain.Edition, error) {
ctx, span := r.tracer.Start(ctx, "FindByISBN")
defer span.End()
var edition domain.Edition var edition domain.Edition
if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&edition).Error; err != nil { if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&edition).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@ -6,12 +6,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type emailVerificationRepository struct { type emailVerificationRepository struct {
domain.BaseRepository[domain.EmailVerification] domain.BaseRepository[domain.EmailVerification]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewEmailVerificationRepository creates a new EmailVerificationRepository. // NewEmailVerificationRepository creates a new EmailVerificationRepository.
@ -19,11 +22,14 @@ func NewEmailVerificationRepository(db *gorm.DB) domain.EmailVerificationReposit
return &emailVerificationRepository{ return &emailVerificationRepository{
BaseRepository: NewBaseRepositoryImpl[domain.EmailVerification](db), BaseRepository: NewBaseRepositoryImpl[domain.EmailVerification](db),
db: db, db: db,
tracer: otel.Tracer("email_verification.repository"),
} }
} }
// GetByToken finds a verification by token (only unused and non-expired) // GetByToken finds a verification by token (only unused and non-expired)
func (r *emailVerificationRepository) GetByToken(ctx context.Context, token string) (*domain.EmailVerification, error) { func (r *emailVerificationRepository) GetByToken(ctx context.Context, token string) (*domain.EmailVerification, error) {
ctx, span := r.tracer.Start(ctx, "GetByToken")
defer span.End()
var verification domain.EmailVerification var verification domain.EmailVerification
if err := r.db.WithContext(ctx).Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&verification).Error; err != nil { if err := r.db.WithContext(ctx).Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&verification).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -36,6 +42,8 @@ func (r *emailVerificationRepository) GetByToken(ctx context.Context, token stri
// GetByUserID finds verifications by user ID // GetByUserID finds verifications by user ID
func (r *emailVerificationRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.EmailVerification, error) { func (r *emailVerificationRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.EmailVerification, error) {
ctx, span := r.tracer.Start(ctx, "GetByUserID")
defer span.End()
var verifications []domain.EmailVerification var verifications []domain.EmailVerification
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&verifications).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&verifications).Error; err != nil {
return nil, err return nil, err
@ -45,6 +53,8 @@ func (r *emailVerificationRepository) GetByUserID(ctx context.Context, userID ui
// DeleteExpired deletes expired verifications // DeleteExpired deletes expired verifications
func (r *emailVerificationRepository) DeleteExpired(ctx context.Context) error { func (r *emailVerificationRepository) DeleteExpired(ctx context.Context) error {
ctx, span := r.tracer.Start(ctx, "DeleteExpired")
defer span.End()
if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.EmailVerification{}).Error; err != nil { if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.EmailVerification{}).Error; err != nil {
return err return err
} }
@ -53,6 +63,8 @@ func (r *emailVerificationRepository) DeleteExpired(ctx context.Context) error {
// MarkAsUsed marks a verification as used // MarkAsUsed marks a verification as used
func (r *emailVerificationRepository) MarkAsUsed(ctx context.Context, id uint) error { func (r *emailVerificationRepository) MarkAsUsed(ctx context.Context, id uint) error {
ctx, span := r.tracer.Start(ctx, "MarkAsUsed")
defer span.End()
if err := r.db.WithContext(ctx).Model(&domain.EmailVerification{}).Where("id = ?", id).Update("used", true).Error; err != nil { if err := r.db.WithContext(ctx).Model(&domain.EmailVerification{}).Where("id = ?", id).Update("used", true).Error; err != nil {
return err return err
} }

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type likeRepository struct { type likeRepository struct {
domain.BaseRepository[domain.Like] domain.BaseRepository[domain.Like]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewLikeRepository creates a new LikeRepository. // NewLikeRepository creates a new LikeRepository.
@ -17,11 +20,14 @@ func NewLikeRepository(db *gorm.DB) domain.LikeRepository {
return &likeRepository{ return &likeRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Like](db), BaseRepository: NewBaseRepositoryImpl[domain.Like](db),
db: db, db: db,
tracer: otel.Tracer("like.repository"),
} }
} }
// ListByUserID finds likes by user ID // ListByUserID finds likes by user ID
func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) {
ctx, span := r.tracer.Start(ctx, "ListByUserID")
defer span.End()
var likes []domain.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&likes).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]domai
// ListByWorkID finds likes by work ID // ListByWorkID finds likes by work ID
func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var likes []domain.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&likes).Error; err != nil {
return nil, err return nil, err
@ -40,6 +48,8 @@ func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domai
// ListByTranslationID finds likes by translation ID // ListByTranslationID finds likes by translation ID
func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) {
ctx, span := r.tracer.Start(ctx, "ListByTranslationID")
defer span.End()
var likes []domain.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&likes).Error; err != nil {
return nil, err return nil, err
@ -49,6 +59,8 @@ func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID
// ListByCommentID finds likes by comment ID // ListByCommentID finds likes by comment ID
func (r *likeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { func (r *likeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) {
ctx, span := r.tracer.Start(ctx, "ListByCommentID")
defer span.End()
var likes []domain.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("comment_id = ?", commentID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("comment_id = ?", commentID).Find(&likes).Error; err != nil {
return nil, err return nil, err

View File

@ -5,18 +5,26 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/localization" "tercul/internal/domain/localization"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type localizationRepository struct { type localizationRepository struct {
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
func NewLocalizationRepository(db *gorm.DB) localization.LocalizationRepository { func NewLocalizationRepository(db *gorm.DB) localization.LocalizationRepository {
return &localizationRepository{db: db} return &localizationRepository{
db: db,
tracer: otel.Tracer("localization.repository"),
}
} }
func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) { func (r *localizationRepository) GetTranslation(ctx context.Context, key string, language string) (string, error) {
ctx, span := r.tracer.Start(ctx, "GetTranslation")
defer span.End()
var l localization.Localization var l localization.Localization
err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&l).Error err := r.db.WithContext(ctx).Where("key = ? AND language = ?", key, language).First(&l).Error
if err != nil { if err != nil {
@ -26,6 +34,8 @@ func (r *localizationRepository) GetTranslation(ctx context.Context, key string,
} }
func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) { func (r *localizationRepository) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) {
ctx, span := r.tracer.Start(ctx, "GetTranslations")
defer span.End()
var localizations []localization.Localization var localizations []localization.Localization
err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error err := r.db.WithContext(ctx).Where("key IN ? AND language = ?", keys, language).Find(&localizations).Error
if err != nil { if err != nil {
@ -39,6 +49,8 @@ func (r *localizationRepository) GetTranslations(ctx context.Context, keys []str
} }
func (r *localizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) { func (r *localizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
ctx, span := r.tracer.Start(ctx, "GetAuthorBiography")
defer span.End()
var translation domain.Translation var translation domain.Translation
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("translatable_type = ? AND translatable_id = ? AND language = ?", "authors", authorID, language). Where("translatable_type = ? AND translatable_id = ? AND language = ?", "authors", authorID, language).
@ -51,3 +63,17 @@ func (r *localizationRepository) GetAuthorBiography(ctx context.Context, authorI
} }
return translation.Content, nil return translation.Content, nil
} }
func (r *localizationRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) {
var translation domain.Translation
err := r.db.WithContext(ctx).
Where("translatable_type = ? AND translatable_id = ? AND language = ?", "works", workID, language).
First(&translation).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", nil
}
return "", err
}
return translation.Content, nil
}

View File

@ -5,12 +5,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/work" "tercul/internal/domain/work"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type monetizationRepository struct { type monetizationRepository struct {
domain.BaseRepository[domain.Monetization] domain.BaseRepository[domain.Monetization]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewMonetizationRepository creates a new MonetizationRepository. // NewMonetizationRepository creates a new MonetizationRepository.
@ -18,64 +21,85 @@ func NewMonetizationRepository(db *gorm.DB) domain.MonetizationRepository {
return &monetizationRepository{ return &monetizationRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Monetization](db), BaseRepository: NewBaseRepositoryImpl[domain.Monetization](db),
db: db, db: db,
tracer: otel.Tracer("monetization.repository"),
} }
} }
func (r *monetizationRepository) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error { func (r *monetizationRepository) AddMonetizationToWork(ctx context.Context, workID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "AddMonetizationToWork")
defer span.End()
workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Append(monetization) return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Append(monetization)
} }
func (r *monetizationRepository) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error { func (r *monetizationRepository) RemoveMonetizationFromWork(ctx context.Context, workID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromWork")
defer span.End()
workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}} workRecord := &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: workID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Delete(monetization) return r.db.WithContext(ctx).Model(workRecord).Association("Monetizations").Delete(monetization)
} }
func (r *monetizationRepository) AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error { func (r *monetizationRepository) AddMonetizationToAuthor(ctx context.Context, authorID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "AddMonetizationToAuthor")
defer span.End()
author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}} author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(author).Association("Monetizations").Append(monetization) return r.db.WithContext(ctx).Model(author).Association("Monetizations").Append(monetization)
} }
func (r *monetizationRepository) RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error { func (r *monetizationRepository) RemoveMonetizationFromAuthor(ctx context.Context, authorID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromAuthor")
defer span.End()
author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}} author := &domain.Author{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: authorID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(author).Association("Monetizations").Delete(monetization) return r.db.WithContext(ctx).Model(author).Association("Monetizations").Delete(monetization)
} }
func (r *monetizationRepository) AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error { func (r *monetizationRepository) AddMonetizationToBook(ctx context.Context, bookID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "AddMonetizationToBook")
defer span.End()
book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}} book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(book).Association("Monetizations").Append(monetization) return r.db.WithContext(ctx).Model(book).Association("Monetizations").Append(monetization)
} }
func (r *monetizationRepository) RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error { func (r *monetizationRepository) RemoveMonetizationFromBook(ctx context.Context, bookID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromBook")
defer span.End()
book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}} book := &domain.Book{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: bookID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(book).Association("Monetizations").Delete(monetization) return r.db.WithContext(ctx).Model(book).Association("Monetizations").Delete(monetization)
} }
func (r *monetizationRepository) AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { func (r *monetizationRepository) AddMonetizationToPublisher(ctx context.Context, publisherID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "AddMonetizationToPublisher")
defer span.End()
publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}} publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Append(monetization) return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Append(monetization)
} }
func (r *monetizationRepository) RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error { func (r *monetizationRepository) RemoveMonetizationFromPublisher(ctx context.Context, publisherID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromPublisher")
defer span.End()
publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}} publisher := &domain.Publisher{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: publisherID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Delete(monetization) return r.db.WithContext(ctx).Model(publisher).Association("Monetizations").Delete(monetization)
} }
func (r *monetizationRepository) AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error { func (r *monetizationRepository) AddMonetizationToSource(ctx context.Context, sourceID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "AddMonetizationToSource")
defer span.End()
source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}} source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(source).Association("Monetizations").Append(monetization) return r.db.WithContext(ctx).Model(source).Association("Monetizations").Append(monetization)
} }
func (r *monetizationRepository) RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error { func (r *monetizationRepository) RemoveMonetizationFromSource(ctx context.Context, sourceID uint, monetizationID uint) error {
ctx, span := r.tracer.Start(ctx, "RemoveMonetizationFromSource")
defer span.End()
source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}} source := &domain.Source{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: sourceID}}}
monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}} monetization := &domain.Monetization{BaseModel: domain.BaseModel{ID: monetizationID}}
return r.db.WithContext(ctx).Model(source).Association("Monetizations").Delete(monetization) return r.db.WithContext(ctx).Model(source).Association("Monetizations").Delete(monetization)

View File

@ -6,12 +6,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type passwordResetRepository struct { type passwordResetRepository struct {
domain.BaseRepository[domain.PasswordReset] domain.BaseRepository[domain.PasswordReset]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewPasswordResetRepository creates a new PasswordResetRepository. // NewPasswordResetRepository creates a new PasswordResetRepository.
@ -19,11 +22,14 @@ func NewPasswordResetRepository(db *gorm.DB) domain.PasswordResetRepository {
return &passwordResetRepository{ return &passwordResetRepository{
BaseRepository: NewBaseRepositoryImpl[domain.PasswordReset](db), BaseRepository: NewBaseRepositoryImpl[domain.PasswordReset](db),
db: db, db: db,
tracer: otel.Tracer("password_reset.repository"),
} }
} }
// GetByToken finds a reset by token (only unused and non-expired) // GetByToken finds a reset by token (only unused and non-expired)
func (r *passwordResetRepository) GetByToken(ctx context.Context, token string) (*domain.PasswordReset, error) { func (r *passwordResetRepository) GetByToken(ctx context.Context, token string) (*domain.PasswordReset, error) {
ctx, span := r.tracer.Start(ctx, "GetByToken")
defer span.End()
var reset domain.PasswordReset var reset domain.PasswordReset
if err := r.db.WithContext(ctx).Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&reset).Error; err != nil { if err := r.db.WithContext(ctx).Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).First(&reset).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -36,6 +42,8 @@ func (r *passwordResetRepository) GetByToken(ctx context.Context, token string)
// GetByUserID finds resets by user ID // GetByUserID finds resets by user ID
func (r *passwordResetRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.PasswordReset, error) { func (r *passwordResetRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.PasswordReset, error) {
ctx, span := r.tracer.Start(ctx, "GetByUserID")
defer span.End()
var resets []domain.PasswordReset var resets []domain.PasswordReset
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&resets).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&resets).Error; err != nil {
return nil, err return nil, err
@ -45,6 +53,8 @@ func (r *passwordResetRepository) GetByUserID(ctx context.Context, userID uint)
// DeleteExpired deletes expired resets // DeleteExpired deletes expired resets
func (r *passwordResetRepository) DeleteExpired(ctx context.Context) error { func (r *passwordResetRepository) DeleteExpired(ctx context.Context) error {
ctx, span := r.tracer.Start(ctx, "DeleteExpired")
defer span.End()
if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.PasswordReset{}).Error; err != nil { if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.PasswordReset{}).Error; err != nil {
return err return err
} }
@ -53,6 +63,8 @@ func (r *passwordResetRepository) DeleteExpired(ctx context.Context) error {
// MarkAsUsed marks a reset as used // MarkAsUsed marks a reset as used
func (r *passwordResetRepository) MarkAsUsed(ctx context.Context, id uint) error { func (r *passwordResetRepository) MarkAsUsed(ctx context.Context, id uint) error {
ctx, span := r.tracer.Start(ctx, "MarkAsUsed")
defer span.End()
if err := r.db.WithContext(ctx).Model(&domain.PasswordReset{}).Where("id = ?", id).Update("used", true).Error; err != nil { if err := r.db.WithContext(ctx).Model(&domain.PasswordReset{}).Where("id = ?", id).Update("used", true).Error; err != nil {
return err return err
} }

View File

@ -5,12 +5,15 @@ import (
"math" "math"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type placeRepository struct { type placeRepository struct {
domain.BaseRepository[domain.Place] domain.BaseRepository[domain.Place]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewPlaceRepository creates a new PlaceRepository. // NewPlaceRepository creates a new PlaceRepository.
@ -18,11 +21,14 @@ func NewPlaceRepository(db *gorm.DB) domain.PlaceRepository {
return &placeRepository{ return &placeRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Place](db), BaseRepository: NewBaseRepositoryImpl[domain.Place](db),
db: db, db: db,
tracer: otel.Tracer("place.repository"),
} }
} }
// ListByCountryID finds places by country ID // ListByCountryID finds places by country ID
func (r *placeRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Place, error) { func (r *placeRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Place, error) {
ctx, span := r.tracer.Start(ctx, "ListByCountryID")
defer span.End()
var places []domain.Place var places []domain.Place
if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&places).Error; err != nil { if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&places).Error; err != nil {
return nil, err return nil, err
@ -32,6 +38,8 @@ func (r *placeRepository) ListByCountryID(ctx context.Context, countryID uint) (
// ListByCityID finds places by city ID // ListByCityID finds places by city ID
func (r *placeRepository) ListByCityID(ctx context.Context, cityID uint) ([]domain.Place, error) { func (r *placeRepository) ListByCityID(ctx context.Context, cityID uint) ([]domain.Place, error) {
ctx, span := r.tracer.Start(ctx, "ListByCityID")
defer span.End()
var places []domain.Place var places []domain.Place
if err := r.db.WithContext(ctx).Where("city_id = ?", cityID).Find(&places).Error; err != nil { if err := r.db.WithContext(ctx).Where("city_id = ?", cityID).Find(&places).Error; err != nil {
return nil, err return nil, err
@ -41,6 +49,8 @@ func (r *placeRepository) ListByCityID(ctx context.Context, cityID uint) ([]doma
// FindNearby finds places within a certain radius (in kilometers) of a point // FindNearby finds places within a certain radius (in kilometers) of a point
func (r *placeRepository) FindNearby(ctx context.Context, latitude, longitude float64, radiusKm float64) ([]domain.Place, error) { func (r *placeRepository) FindNearby(ctx context.Context, latitude, longitude float64, radiusKm float64) ([]domain.Place, error) {
ctx, span := r.tracer.Start(ctx, "FindNearby")
defer span.End()
// This is a simplified implementation that would need to be replaced with // This is a simplified implementation that would need to be replaced with
// a proper geospatial query based on the database being used // a proper geospatial query based on the database being used
var places []domain.Place var places []domain.Place

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type publisherRepository struct { type publisherRepository struct {
domain.BaseRepository[domain.Publisher] domain.BaseRepository[domain.Publisher]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewPublisherRepository creates a new PublisherRepository. // NewPublisherRepository creates a new PublisherRepository.
@ -17,11 +20,14 @@ func NewPublisherRepository(db *gorm.DB) domain.PublisherRepository {
return &publisherRepository{ return &publisherRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Publisher](db), BaseRepository: NewBaseRepositoryImpl[domain.Publisher](db),
db: db, db: db,
tracer: otel.Tracer("publisher.repository"),
} }
} }
// ListByCountryID finds publishers by country ID // ListByCountryID finds publishers by country ID
func (r *publisherRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Publisher, error) { func (r *publisherRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Publisher, error) {
ctx, span := r.tracer.Start(ctx, "ListByCountryID")
defer span.End()
var publishers []domain.Publisher var publishers []domain.Publisher
if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&publishers).Error; err != nil { if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&publishers).Error; err != nil {
return nil, err return nil, err

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type sourceRepository struct { type sourceRepository struct {
domain.BaseRepository[domain.Source] domain.BaseRepository[domain.Source]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewSourceRepository creates a new SourceRepository. // NewSourceRepository creates a new SourceRepository.
@ -18,11 +21,14 @@ func NewSourceRepository(db *gorm.DB) domain.SourceRepository {
return &sourceRepository{ return &sourceRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Source](db), BaseRepository: NewBaseRepositoryImpl[domain.Source](db),
db: db, db: db,
tracer: otel.Tracer("source.repository"),
} }
} }
// ListByWorkID finds sources by work ID // ListByWorkID finds sources by work ID
func (r *sourceRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Source, error) { func (r *sourceRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Source, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var sources []domain.Source var sources []domain.Source
if err := r.db.WithContext(ctx).Joins("JOIN work_sources ON work_sources.source_id = sources.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_sources ON work_sources.source_id = sources.id").
Where("work_sources.work_id = ?", workID). Where("work_sources.work_id = ?", workID).
@ -34,6 +40,8 @@ func (r *sourceRepository) ListByWorkID(ctx context.Context, workID uint) ([]dom
// FindByURL finds a source by URL // FindByURL finds a source by URL
func (r *sourceRepository) FindByURL(ctx context.Context, url string) (*domain.Source, error) { func (r *sourceRepository) FindByURL(ctx context.Context, url string) (*domain.Source, error) {
ctx, span := r.tracer.Start(ctx, "FindByURL")
defer span.End()
var source domain.Source var source domain.Source
if err := r.db.WithContext(ctx).Where("url = ?", url).First(&source).Error; err != nil { if err := r.db.WithContext(ctx).Where("url = ?", url).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type tagRepository struct { type tagRepository struct {
domain.BaseRepository[domain.Tag] domain.BaseRepository[domain.Tag]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewTagRepository creates a new TagRepository. // NewTagRepository creates a new TagRepository.
@ -18,11 +21,14 @@ func NewTagRepository(db *gorm.DB) domain.TagRepository {
return &tagRepository{ return &tagRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Tag](db), BaseRepository: NewBaseRepositoryImpl[domain.Tag](db),
db: db, db: db,
tracer: otel.Tracer("tag.repository"),
} }
} }
// FindByName finds a tag by name // FindByName finds a tag by name
func (r *tagRepository) FindByName(ctx context.Context, name string) (*domain.Tag, error) { func (r *tagRepository) FindByName(ctx context.Context, name string) (*domain.Tag, error) {
ctx, span := r.tracer.Start(ctx, "FindByName")
defer span.End()
var tag domain.Tag var tag domain.Tag
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil { if err := r.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -35,6 +41,8 @@ func (r *tagRepository) FindByName(ctx context.Context, name string) (*domain.Ta
// ListByWorkID finds tags by work ID // ListByWorkID finds tags by work ID
func (r *tagRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) { func (r *tagRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var tags []domain.Tag var tags []domain.Tag
if err := r.db.WithContext(ctx).Joins("JOIN work_tags ON work_tags.tag_id = tags.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_tags ON work_tags.tag_id = tags.id").
Where("work_tags.work_id = ?", workID). Where("work_tags.work_id = ?", workID).

View File

@ -4,12 +4,15 @@ import (
"context" "context"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type translationRepository struct { type translationRepository struct {
domain.BaseRepository[domain.Translation] domain.BaseRepository[domain.Translation]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewTranslationRepository creates a new TranslationRepository. // NewTranslationRepository creates a new TranslationRepository.
@ -17,11 +20,14 @@ func NewTranslationRepository(db *gorm.DB) domain.TranslationRepository {
return &translationRepository{ return &translationRepository{
BaseRepository: NewBaseRepositoryImpl[domain.Translation](db), BaseRepository: NewBaseRepositoryImpl[domain.Translation](db),
db: db, db: db,
tracer: otel.Tracer("translation.repository"),
} }
} }
// ListByWorkID finds translations by work ID // ListByWorkID finds translations by work ID
func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
ctx, span := r.tracer.Start(ctx, "ListByWorkID")
defer span.End()
var translations []domain.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "works").Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "works").Find(&translations).Error; err != nil {
return nil, err return nil, err
@ -31,6 +37,8 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) (
// ListByEntity finds translations by entity type and ID // ListByEntity finds translations by entity type and ID
func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
ctx, span := r.tracer.Start(ctx, "ListByEntity")
defer span.End()
var translations []domain.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", entityID, entityType).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", entityID, entityType).Find(&translations).Error; err != nil {
return nil, err return nil, err
@ -40,6 +48,8 @@ func (r *translationRepository) ListByEntity(ctx context.Context, entityType str
// ListByTranslatorID finds translations by translator ID // ListByTranslatorID finds translations by translator ID
func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) { func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
ctx, span := r.tracer.Start(ctx, "ListByTranslatorID")
defer span.End()
var translations []domain.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translator_id = ?", translatorID).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translator_id = ?", translatorID).Find(&translations).Error; err != nil {
return nil, err return nil, err
@ -49,6 +59,8 @@ func (r *translationRepository) ListByTranslatorID(ctx context.Context, translat
// ListByStatus finds translations by status // ListByStatus finds translations by status
func (r *translationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { func (r *translationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) {
ctx, span := r.tracer.Start(ctx, "ListByStatus")
defer span.End()
var translations []domain.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&translations).Error; err != nil {
return nil, err return nil, err

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type userProfileRepository struct { type userProfileRepository struct {
domain.BaseRepository[domain.UserProfile] domain.BaseRepository[domain.UserProfile]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewUserProfileRepository creates a new UserProfileRepository. // NewUserProfileRepository creates a new UserProfileRepository.
@ -18,11 +21,14 @@ func NewUserProfileRepository(db *gorm.DB) domain.UserProfileRepository {
return &userProfileRepository{ return &userProfileRepository{
BaseRepository: NewBaseRepositoryImpl[domain.UserProfile](db), BaseRepository: NewBaseRepositoryImpl[domain.UserProfile](db),
db: db, db: db,
tracer: otel.Tracer("user_profile.repository"),
} }
} }
// GetByUserID finds a user profile by user ID // GetByUserID finds a user profile by user ID
func (r *userProfileRepository) GetByUserID(ctx context.Context, userID uint) (*domain.UserProfile, error) { func (r *userProfileRepository) GetByUserID(ctx context.Context, userID uint) (*domain.UserProfile, error) {
ctx, span := r.tracer.Start(ctx, "GetByUserID")
defer span.End()
var profile domain.UserProfile var profile domain.UserProfile
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&profile).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&profile).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@ -5,12 +5,15 @@ import (
"errors" "errors"
"tercul/internal/domain" "tercul/internal/domain"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type userRepository struct { type userRepository struct {
domain.BaseRepository[domain.User] domain.BaseRepository[domain.User]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewUserRepository creates a new UserRepository. // NewUserRepository creates a new UserRepository.
@ -18,11 +21,14 @@ func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &userRepository{ return &userRepository{
BaseRepository: NewBaseRepositoryImpl[domain.User](db), BaseRepository: NewBaseRepositoryImpl[domain.User](db),
db: db, db: db,
tracer: otel.Tracer("user.repository"),
} }
} }
// FindByUsername finds a user by username // FindByUsername finds a user by username
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) { func (r *userRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
ctx, span := r.tracer.Start(ctx, "FindByUsername")
defer span.End()
var user domain.User var user domain.User
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -35,6 +41,8 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (*
// FindByEmail finds a user by email // FindByEmail finds a user by email
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
ctx, span := r.tracer.Start(ctx, "FindByEmail")
defer span.End()
var user domain.User var user domain.User
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -47,6 +55,8 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain
// ListByRole lists users by role // ListByRole lists users by role
func (r *userRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { func (r *userRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) {
ctx, span := r.tracer.Start(ctx, "ListByRole")
defer span.End()
var users []domain.User var users []domain.User
if err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error; err != nil { if err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error; err != nil {
return nil, err return nil, err

View File

@ -6,12 +6,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"time" "time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type userSessionRepository struct { type userSessionRepository struct {
domain.BaseRepository[domain.UserSession] domain.BaseRepository[domain.UserSession]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewUserSessionRepository creates a new UserSessionRepository. // NewUserSessionRepository creates a new UserSessionRepository.
@ -19,11 +22,14 @@ func NewUserSessionRepository(db *gorm.DB) domain.UserSessionRepository {
return &userSessionRepository{ return &userSessionRepository{
BaseRepository: NewBaseRepositoryImpl[domain.UserSession](db), BaseRepository: NewBaseRepositoryImpl[domain.UserSession](db),
db: db, db: db,
tracer: otel.Tracer("user_session.repository"),
} }
} }
// GetByToken finds a session by token // GetByToken finds a session by token
func (r *userSessionRepository) GetByToken(ctx context.Context, token string) (*domain.UserSession, error) { func (r *userSessionRepository) GetByToken(ctx context.Context, token string) (*domain.UserSession, error) {
ctx, span := r.tracer.Start(ctx, "GetByToken")
defer span.End()
var session domain.UserSession var session domain.UserSession
if err := r.db.WithContext(ctx).Where("token = ?", token).First(&session).Error; err != nil { if err := r.db.WithContext(ctx).Where("token = ?", token).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -36,6 +42,8 @@ func (r *userSessionRepository) GetByToken(ctx context.Context, token string) (*
// GetByUserID finds sessions by user ID // GetByUserID finds sessions by user ID
func (r *userSessionRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.UserSession, error) { func (r *userSessionRepository) GetByUserID(ctx context.Context, userID uint) ([]domain.UserSession, error) {
ctx, span := r.tracer.Start(ctx, "GetByUserID")
defer span.End()
var sessions []domain.UserSession var sessions []domain.UserSession
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&sessions).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&sessions).Error; err != nil {
return nil, err return nil, err
@ -45,6 +53,8 @@ func (r *userSessionRepository) GetByUserID(ctx context.Context, userID uint) ([
// DeleteExpired deletes expired sessions // DeleteExpired deletes expired sessions
func (r *userSessionRepository) DeleteExpired(ctx context.Context) error { func (r *userSessionRepository) DeleteExpired(ctx context.Context) error {
ctx, span := r.tracer.Start(ctx, "DeleteExpired")
defer span.End()
if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.UserSession{}).Error; err != nil { if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&domain.UserSession{}).Error; err != nil {
return err return err
} }

View File

@ -7,12 +7,15 @@ import (
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/work" "tercul/internal/domain/work"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
type workRepository struct { type workRepository struct {
domain.BaseRepository[work.Work] domain.BaseRepository[work.Work]
db *gorm.DB db *gorm.DB
tracer trace.Tracer
} }
// NewWorkRepository creates a new WorkRepository. // NewWorkRepository creates a new WorkRepository.
@ -20,11 +23,14 @@ func NewWorkRepository(db *gorm.DB) work.WorkRepository {
return &workRepository{ return &workRepository{
BaseRepository: NewBaseRepositoryImpl[work.Work](db), BaseRepository: NewBaseRepositoryImpl[work.Work](db),
db: db, db: db,
tracer: otel.Tracer("work.repository"),
} }
} }
// FindByTitle finds works by title (partial match) // FindByTitle finds works by title (partial match)
func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByTitle")
defer span.End()
var works []work.Work var works []work.Work
if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil { if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil {
return nil, err return nil, err
@ -34,6 +40,8 @@ func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]work.
// FindByAuthor finds works by author ID // FindByAuthor finds works by author ID
func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByAuthor")
defer span.End()
var works []work.Work var works []work.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id").
Where("work_authors.author_id = ?", authorID). Where("work_authors.author_id = ?", authorID).
@ -45,6 +53,8 @@ func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]wor
// FindByCategory finds works by category ID // FindByCategory finds works by category ID
func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
ctx, span := r.tracer.Start(ctx, "FindByCategory")
defer span.End()
var works []work.Work var works []work.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id").
Where("work_categories.category_id = ?", categoryID). Where("work_categories.category_id = ?", categoryID).
@ -56,6 +66,8 @@ func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([
// FindByLanguage finds works by language with pagination // FindByLanguage finds works by language with pagination
func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
ctx, span := r.tracer.Start(ctx, "FindByLanguage")
defer span.End()
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@ -104,6 +116,8 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa
// Delete removes a work and its associations // Delete removes a work and its associations
func (r *workRepository) Delete(ctx context.Context, id uint) error { func (r *workRepository) Delete(ctx context.Context, id uint) error {
ctx, span := r.tracer.Start(ctx, "Delete")
defer span.End()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Manually delete associations // Manually delete associations
if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil { if err := tx.Select("Copyrights", "Monetizations", "Authors", "Tags", "Categories").Delete(&work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}).Error; err != nil {
@ -119,11 +133,15 @@ func (r *workRepository) Delete(ctx context.Context, id uint) error {
// GetWithTranslations gets a work with its translations // GetWithTranslations gets a work with its translations
func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithTranslations")
defer span.End()
return r.FindWithPreload(ctx, []string{"Translations"}, id) return r.FindWithPreload(ctx, []string{"Translations"}, id)
} }
// GetWithAssociations gets a work with all of its direct and many-to-many associations. // GetWithAssociations gets a work with all of its direct and many-to-many associations.
func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithAssociations")
defer span.End()
associations := []string{ associations := []string{
"Translations", "Translations",
"Authors", "Authors",
@ -137,6 +155,8 @@ func (r *workRepository) GetWithAssociations(ctx context.Context, id uint) (*wor
// GetWithAssociationsInTx gets a work with all associations within a transaction. // GetWithAssociationsInTx gets a work with all associations within a transaction.
func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) {
ctx, span := r.tracer.Start(ctx, "GetWithAssociationsInTx")
defer span.End()
var entity work.Work var entity work.Work
query := tx.WithContext(ctx) query := tx.WithContext(ctx)
associations := []string{ associations := []string{
@ -163,6 +183,8 @@ func (r *workRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.D
// Note: This assumes a direct relationship between user ID and author ID, // Note: This assumes a direct relationship between user ID and author ID,
// which may need to be revised based on the actual domain model. // which may need to be revised based on the actual domain model.
func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
ctx, span := r.tracer.Start(ctx, "IsAuthor")
defer span.End()
var count int64 var count int64
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table("work_authors"). Table("work_authors").
@ -176,6 +198,8 @@ func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uin
// ListWithTranslations lists works with their translations // ListWithTranslations lists works with their translations
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
ctx, span := r.tracer.Start(ctx, "ListWithTranslations")
defer span.End()
if page < 1 { if page < 1 {
page = 1 page = 1
} }

View File

@ -195,6 +195,7 @@ type Author struct {
Status AuthorStatus `gorm:"size:50;default:'active'"` Status AuthorStatus `gorm:"size:50;default:'active'"`
BirthDate *time.Time BirthDate *time.Time
DeathDate *time.Time DeathDate *time.Time
OpenLibraryID string `gorm:"size:50;index"`
Books []*Book `gorm:"many2many:book_authors"` Books []*Book `gorm:"many2many:book_authors"`
CountryID *uint CountryID *uint
Country *Country `gorm:"foreignKey:CountryID"` Country *Country `gorm:"foreignKey:CountryID"`

View File

@ -2,19 +2,16 @@ package domain
import "errors" import "errors"
// Common domain-level errors that can be used across repositories and services.
var ( var (
// ErrNotFound indicates that a requested resource was not found. ErrEntityNotFound = errors.New("entity not found")
ErrNotFound = errors.New("not found") ErrInvalidID = errors.New("invalid ID: cannot be zero")
ErrInvalidInput = errors.New("invalid input parameters")
// ErrUnauthorized indicates that the user is not authenticated. ErrDatabaseOperation = errors.New("database operation failed")
ErrUnauthorized = errors.New("unauthorized") ErrContextRequired = errors.New("context is required")
ErrTransactionFailed = errors.New("transaction failed")
// ErrForbidden indicates that the user is authenticated but not authorized to perform the action. ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
ErrValidation = errors.New("validation failed")
// ErrValidation indicates that the input failed validation. ErrConflict = errors.New("conflict with existing resource")
ErrValidation = errors.New("validation failed")
// ErrConflict indicates a conflict with the current state of the resource (e.g., duplicate).
ErrConflict = errors.New("conflict")
) )

View File

@ -9,4 +9,5 @@ type LocalizationRepository interface {
GetTranslation(ctx context.Context, key string, language string) (string, error) GetTranslation(ctx context.Context, key string, language string) (string, error)
GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error) GetTranslations(ctx context.Context, keys []string, language string) (map[string]string, error)
GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error)
GetWorkContent(ctx context.Context, workID uint, language string) (string, error)
} }

View File

@ -0,0 +1,78 @@
package enrichment
import (
"context"
"fmt"
"tercul/internal/domain"
"tercul/internal/platform/openlibrary"
)
// OpenLibraryAuthorEnricher enriches author data from the Open Library API.
type OpenLibraryAuthorEnricher struct {
client *openlibrary.Client
}
// NewOpenLibraryAuthorEnricher creates a new OpenLibraryAuthorEnricher.
func NewOpenLibraryAuthorEnricher() *OpenLibraryAuthorEnricher {
return &OpenLibraryAuthorEnricher{
client: openlibrary.NewClient(),
}
}
// Name returns the name of the enricher.
func (e *OpenLibraryAuthorEnricher) Name() string {
return "openlibrary_author_enricher"
}
// Enrich fetches data from the Open Library API and enriches the author.
func (e *OpenLibraryAuthorEnricher) Enrich(ctx context.Context, author *domain.Author) error {
if author.OpenLibraryID == "" {
// No OLID to look up.
return nil
}
olAuthor, err := e.client.GetAuthor(ctx, author.OpenLibraryID)
if err != nil {
return fmt.Errorf("failed to get author from Open Library: %w", err)
}
if olAuthor.Bio != nil {
// The bio can be a string or a struct with a 'value' field.
if bioStr, ok := olAuthor.Bio.(string); ok {
// Find or create the English translation for the bio.
e.updateBioTranslation(author, bioStr)
} else if bioMap, ok := olAuthor.Bio.(map[string]interface{}); ok {
if bioValue, ok := bioMap["value"].(string); ok {
e.updateBioTranslation(author, bioValue)
}
}
}
return nil
}
func (e *OpenLibraryAuthorEnricher) updateBioTranslation(author *domain.Author, bio string) {
// This is a simplified implementation. A real one would need to handle
// creating or updating a translation record associated with the author.
// For now, we'll just append it to the author's existing bio if it's empty.
var bioTranslation *domain.Translation
for _, t := range author.Translations {
if t.TranslatableType == "authors" && t.Language == "en" {
bioTranslation = t
break
}
}
if bioTranslation == nil {
author.Translations = append(author.Translations, &domain.Translation{
Content: bio,
Language: "en",
TranslatableID: author.ID,
TranslatableType: "authors",
})
} else {
if bioTranslation.Content == "" {
bioTranslation.Content = bio
}
}
}

View File

@ -0,0 +1,67 @@
package enrichment
import (
"context"
"tercul/internal/domain"
"tercul/internal/domain/work"
)
// Service is the main entrypoint for the enrichment functionality.
// It orchestrates different enrichers for various domain entities.
type Service struct {
AuthorEnrichers []AuthorEnricher
WorkEnrichers []WorkEnricher
}
// NewService creates a new enrichment Service.
func NewService() *Service {
service := &Service{
AuthorEnrichers: []AuthorEnricher{},
WorkEnrichers: []WorkEnricher{},
}
service.RegisterAuthorEnricher(NewOpenLibraryAuthorEnricher())
return service
}
// AuthorEnricher defines the interface for enriching author data.
type AuthorEnricher interface {
Enrich(ctx context.Context, author *domain.Author) error
Name() string
}
// WorkEnricher defines the interface for enriching work data.
type WorkEnricher interface {
Enrich(ctx context.Context, work *work.Work) error
Name() string
}
// RegisterAuthorEnricher adds a new author enricher to the service.
func (s *Service) RegisterAuthorEnricher(enricher AuthorEnricher) {
s.AuthorEnrichers = append(s.AuthorEnrichers, enricher)
}
// RegisterWorkEnricher adds a new work enricher to the service.
func (s *Service) RegisterWorkEnricher(enricher WorkEnricher) {
s.WorkEnrichers = append(s.WorkEnrichers, enricher)
}
// EnrichAuthor iterates through registered author enrichers and applies them.
func (s *Service) EnrichAuthor(ctx context.Context, author *domain.Author) error {
for _, enricher := range s.AuthorEnrichers {
if err := enricher.Enrich(ctx, author); err != nil {
// In a real implementation, we might want to log errors but continue.
return err
}
}
return nil
}
// EnrichWork iterates through registered work enrichers and applies them.
func (s *Service) EnrichWork(ctx context.Context, work *work.Work) error {
for _, enricher := range s.WorkEnrichers {
if err := enricher.Enrich(ctx, work); err != nil {
return err
}
}
return nil
}

View File

@ -117,9 +117,7 @@ func (c *RedisAnalysisCache) Set(ctx context.Context, key string, result *Analys
ttlSeconds := config.Cfg.NLPRedisCacheTTLSeconds ttlSeconds := config.Cfg.NLPRedisCacheTTLSeconds
err := c.cache.Set(ctx, key, result, time.Duration(ttlSeconds)*time.Second) err := c.cache.Set(ctx, key, result, time.Duration(ttlSeconds)*time.Second)
if err != nil { if err != nil {
log.LogWarn("Failed to cache analysis result", log.FromContext(ctx).With("key", key).Error(err, "Failed to cache analysis result")
log.F("key", key),
log.F("error", err))
return err return err
} }
@ -176,16 +174,12 @@ func (c *CompositeAnalysisCache) Set(ctx context.Context, key string, result *An
// Set in memory cache // Set in memory cache
if err := c.memoryCache.Set(ctx, key, result); err != nil { if err := c.memoryCache.Set(ctx, key, result); err != nil {
log.LogWarn("Failed to set memory cache", log.FromContext(ctx).With("key", key).Error(err, "Failed to set memory cache")
log.F("key", key),
log.F("error", err))
} }
// Set in Redis cache // Set in Redis cache
if err := c.redisCache.Set(ctx, key, result); err != nil { if err := c.redisCache.Set(ctx, key, result); err != nil {
log.LogWarn("Failed to set Redis cache", log.FromContext(ctx).With("key", key).Error(err, "Failed to set Redis cache")
log.F("key", key),
log.F("error", err))
return err return err
} }

View File

@ -41,6 +41,7 @@ func NewGORMAnalysisRepository(db *gorm.DB) *GORMAnalysisRepository {
// StoreAnalysisResults stores analysis results in the database // StoreAnalysisResults stores analysis results in the database
func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workID uint, result *AnalysisResult) error { func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workID uint, result *AnalysisResult) error {
logger := log.FromContext(ctx).With("workID", workID)
if result == nil { if result == nil {
return fmt.Errorf("analysis result cannot be nil") return fmt.Errorf("analysis result cannot be nil")
} }
@ -48,9 +49,7 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI
// Determine language from the work record to avoid hardcoded defaults // Determine language from the work record to avoid hardcoded defaults
var workRecord work.Work var workRecord work.Work
if err := r.db.WithContext(ctx).First(&workRecord, workID).Error; err != nil { if err := r.db.WithContext(ctx).First(&workRecord, workID).Error; err != nil {
log.LogError("Failed to fetch work for language", logger.Error(err, "Failed to fetch work for language")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to fetch work for language: %w", err) return fmt.Errorf("failed to fetch work for language: %w", err)
} }
@ -89,12 +88,11 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI
// GetWorkContent retrieves content for a work from translations // GetWorkContent retrieves content for a work from translations
func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) { func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) {
logger := log.FromContext(ctx).With("workID", workID)
// First, get the work to determine its language // First, get the work to determine its language
var workRecord work.Work var workRecord work.Work
if err := r.db.First(&workRecord, workID).Error; err != nil { if err := r.db.First(&workRecord, workID).Error; err != nil {
log.LogError("Failed to fetch work for content retrieval", logger.Error(err, "Failed to fetch work for content retrieval")
log.F("workID", workID),
log.F("error", err))
return "", fmt.Errorf("failed to fetch work: %w", err) return "", fmt.Errorf("failed to fetch work: %w", err)
} }
@ -107,19 +105,19 @@ func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint
// Try original language first // Try original language first
if err := r.db.Where("translatable_type = ? AND translatable_id = ? AND is_original_language = ?", if err := r.db.Where("translatable_type = ? AND translatable_id = ? AND is_original_language = ?",
"Work", workID, true).First(&translation).Error; err == nil { "works", workID, true).First(&translation).Error; err == nil {
return translation.Content, nil return translation.Content, nil
} }
// Try work's language // Try work's language
if err := r.db.Where("translatable_type = ? AND translatable_id = ? AND language = ?", if err := r.db.Where("translatable_type = ? AND translatable_id = ? AND language = ?",
"Work", workID, workRecord.Language).First(&translation).Error; err == nil { "works", workID, workRecord.Language).First(&translation).Error; err == nil {
return translation.Content, nil return translation.Content, nil
} }
// Try any available translation // Try any available translation
if err := r.db.Where("translatable_type = ? AND translatable_id = ?", if err := r.db.Where("translatable_type = ? AND translatable_id = ?",
"Work", workID).First(&translation).Error; err == nil { "works", workID).First(&translation).Error; err == nil {
return translation.Content, nil return translation.Content, nil
} }
@ -137,23 +135,21 @@ func (r *GORMAnalysisRepository) GetWorkByID(ctx context.Context, workID uint) (
// GetAnalysisData fetches persisted analysis data for a work // GetAnalysisData fetches persisted analysis data for a work
func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uint) (*domain.TextMetadata, *domain.ReadabilityScore, *domain.LanguageAnalysis, error) { func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uint) (*domain.TextMetadata, *domain.ReadabilityScore, *domain.LanguageAnalysis, error) {
logger := log.FromContext(ctx).With("workID", workID)
var textMetadata domain.TextMetadata var textMetadata domain.TextMetadata
var readabilityScore domain.ReadabilityScore var readabilityScore domain.ReadabilityScore
var languageAnalysis domain.LanguageAnalysis var languageAnalysis domain.LanguageAnalysis
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&textMetadata).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&textMetadata).Error; err != nil {
log.LogWarn("No text metadata found for work", logger.Warn("No text metadata found for work")
log.F("workID", workID))
} }
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&readabilityScore).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&readabilityScore).Error; err != nil {
log.LogWarn("No readability score found for work", logger.Warn("No readability score found for work")
log.F("workID", workID))
} }
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&languageAnalysis).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&languageAnalysis).Error; err != nil {
log.LogWarn("No language analysis found for work", logger.Warn("No language analysis found for work")
log.F("workID", workID))
return nil, nil, nil, err return nil, nil, nil, err
} }
@ -164,22 +160,18 @@ func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uin
func (r *GORMAnalysisRepository) StoreWorkAnalysis(ctx context.Context, workID uint, func (r *GORMAnalysisRepository) StoreWorkAnalysis(ctx context.Context, workID uint,
textMetadata *domain.TextMetadata, readabilityScore *domain.ReadabilityScore, textMetadata *domain.TextMetadata, readabilityScore *domain.ReadabilityScore,
languageAnalysis *domain.LanguageAnalysis) error { languageAnalysis *domain.LanguageAnalysis) error {
logger := log.FromContext(ctx).With("workID", workID)
// Use a transaction to ensure all data is stored atomically // Use a transaction to ensure all data is stored atomically
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Store text metadata // Store text metadata
if textMetadata != nil { if textMetadata != nil {
if err := tx.Where("work_id = ?", workID).Delete(&domain.TextMetadata{}).Error; err != nil { if err := tx.Where("work_id = ?", workID).Delete(&domain.TextMetadata{}).Error; err != nil {
log.LogError("Failed to delete existing text metadata", logger.Error(err, "Failed to delete existing text metadata")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to delete existing text metadata: %w", err) return fmt.Errorf("failed to delete existing text metadata: %w", err)
} }
if err := tx.Create(textMetadata).Error; err != nil { if err := tx.Create(textMetadata).Error; err != nil {
log.LogError("Failed to store text metadata", logger.Error(err, "Failed to store text metadata")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to store text metadata: %w", err) return fmt.Errorf("failed to store text metadata: %w", err)
} }
} }
@ -187,16 +179,12 @@ func (r *GORMAnalysisRepository) StoreWorkAnalysis(ctx context.Context, workID u
// Store readability score // Store readability score
if readabilityScore != nil { if readabilityScore != nil {
if err := tx.Where("work_id = ?", workID).Delete(&domain.ReadabilityScore{}).Error; err != nil { if err := tx.Where("work_id = ?", workID).Delete(&domain.ReadabilityScore{}).Error; err != nil {
log.LogError("Failed to delete existing readability score", logger.Error(err, "Failed to delete existing readability score")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to delete existing readability score: %w", err) return fmt.Errorf("failed to delete existing readability score: %w", err)
} }
if err := tx.Create(readabilityScore).Error; err != nil { if err := tx.Create(readabilityScore).Error; err != nil {
log.LogError("Failed to store readability score", logger.Error(err, "Failed to store readability score")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to store readability score: %w", err) return fmt.Errorf("failed to store readability score: %w", err)
} }
} }
@ -204,22 +192,17 @@ func (r *GORMAnalysisRepository) StoreWorkAnalysis(ctx context.Context, workID u
// Store language analysis // Store language analysis
if languageAnalysis != nil { if languageAnalysis != nil {
if err := tx.Where("work_id = ?", workID).Delete(&domain.LanguageAnalysis{}).Error; err != nil { if err := tx.Where("work_id = ?", workID).Delete(&domain.LanguageAnalysis{}).Error; err != nil {
log.LogError("Failed to delete existing language analysis", logger.Error(err, "Failed to delete existing language analysis")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to delete existing language analysis: %w", err) return fmt.Errorf("failed to delete existing language analysis: %w", err)
} }
if err := tx.Create(languageAnalysis).Error; err != nil { if err := tx.Create(languageAnalysis).Error; err != nil {
log.LogError("Failed to store language analysis", logger.Error(err, "Failed to store language analysis")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to store language analysis: %w", err) return fmt.Errorf("failed to store language analysis: %w", err)
} }
} }
log.LogInfo("Successfully stored analysis results", logger.Info("Successfully stored analysis results")
log.F("workID", workID))
return nil return nil
}) })

View File

@ -79,6 +79,7 @@ func (a *BasicAnalyzer) DisableCache() {
// AnalyzeText performs basic linguistic analysis on the given text // AnalyzeText performs basic linguistic analysis on the given text
func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language string) (*AnalysisResult, error) { func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language string) (*AnalysisResult, error) {
logger := log.FromContext(ctx).With("language", language).With("textLength", len(text))
// Check in-memory cache first if enabled // Check in-memory cache first if enabled
if a.cacheEnabled { if a.cacheEnabled {
cacheKey := makeTextCacheKey(language, text) cacheKey := makeTextCacheKey(language, text)
@ -89,9 +90,7 @@ func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language s
a.cacheMutex.RUnlock() a.cacheMutex.RUnlock()
if found { if found {
log.LogDebug("In-memory cache hit for text analysis", logger.Debug("In-memory cache hit for text analysis")
log.F("language", language),
log.F("textLength", len(text)))
return cachedResult, nil return cachedResult, nil
} }
@ -100,9 +99,7 @@ func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language s
var cachedResult AnalysisResult var cachedResult AnalysisResult
err := a.cache.Get(ctx, "text_analysis:"+cacheKey, &cachedResult) err := a.cache.Get(ctx, "text_analysis:"+cacheKey, &cachedResult)
if err == nil { if err == nil {
log.LogDebug("Redis cache hit for text analysis", logger.Debug("Redis cache hit for text analysis")
log.F("language", language),
log.F("textLength", len(text)))
// Store in in-memory cache too // Store in in-memory cache too
a.cacheMutex.Lock() a.cacheMutex.Lock()
@ -115,9 +112,7 @@ func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language s
} }
// Cache miss or caching disabled, perform analysis using the pure TextAnalyzer // Cache miss or caching disabled, perform analysis using the pure TextAnalyzer
log.LogDebug("Performing text analysis", logger.Debug("Performing text analysis")
log.F("language", language),
log.F("textLength", len(text)))
var ( var (
result *AnalysisResult result *AnalysisResult
@ -144,10 +139,7 @@ func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language s
// Store in Redis cache if available // Store in Redis cache if available
if a.cache != nil { if a.cache != nil {
if err := a.cache.Set(ctx, "text_analysis:"+cacheKey, result, 0); err != nil { if err := a.cache.Set(ctx, "text_analysis:"+cacheKey, result, 0); err != nil {
log.LogWarn("Failed to cache text analysis result", logger.Error(err, "Failed to cache text analysis result")
log.F("language", language),
log.F("textLength", len(text)),
log.F("error", err))
} }
} }
} }

View File

@ -68,6 +68,8 @@ func NewWorkAnalysisService(
// AnalyzeWork performs linguistic analysis on a work and stores the results // AnalyzeWork performs linguistic analysis on a work and stores the results
func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) error { func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) error {
logger := log.FromContext(ctx).With("workID", workID)
if workID == 0 { if workID == 0 {
return fmt.Errorf("invalid work ID") return fmt.Errorf("invalid work ID")
} }
@ -77,8 +79,7 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro
cacheKey := fmt.Sprintf("work_analysis:%d", workID) cacheKey := fmt.Sprintf("work_analysis:%d", workID)
if result, err := s.analysisCache.Get(ctx, cacheKey); err == nil { if result, err := s.analysisCache.Get(ctx, cacheKey); err == nil {
log.LogInfo("Cache hit for work analysis", logger.Info("Cache hit for work analysis")
log.F("workID", workID))
// Store directly to database // Store directly to database
return s.analysisRepo.StoreAnalysisResults(ctx, workID, result) return s.analysisRepo.StoreAnalysisResults(ctx, workID, result)
@ -88,34 +89,28 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro
// Get work content from database // Get work content from database
content, err := s.analysisRepo.GetWorkContent(ctx, workID, "") content, err := s.analysisRepo.GetWorkContent(ctx, workID, "")
if err != nil { if err != nil {
log.LogError("Failed to get work content for analysis", logger.Error(err, "Failed to get work content for analysis")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to get work content: %w", err) return fmt.Errorf("failed to get work content: %w", err)
} }
// Skip analysis if content is empty // Skip analysis if content is empty
if content == "" { if content == "" {
log.LogWarn("Skipping analysis for work with empty content", logger.Warn("Skipping analysis for work with empty content")
log.F("workID", workID))
return nil return nil
} }
// Get work to determine language (via repository to avoid leaking GORM) // Get work to determine language (via repository to avoid leaking GORM)
work, err := s.analysisRepo.GetWorkByID(ctx, workID) work, err := s.analysisRepo.GetWorkByID(ctx, workID)
if err != nil { if err != nil {
log.LogError("Failed to fetch work for analysis", logger.Error(err, "Failed to fetch work for analysis")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to fetch work: %w", err) return fmt.Errorf("failed to fetch work: %w", err)
} }
// Analyze the text // Analyze the text
start := time.Now() start := time.Now()
log.LogInfo("Analyzing work", logger.With("language", work.Language).
log.F("workID", workID), With("contentLength", len(content)).
log.F("language", work.Language), Info("Analyzing work")
log.F("contentLength", len(content)))
var result *AnalysisResult var result *AnalysisResult
@ -127,17 +122,13 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro
} }
if err != nil { if err != nil {
log.LogError("Failed to analyze work text", logger.Error(err, "Failed to analyze work text")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to analyze work text: %w", err) return fmt.Errorf("failed to analyze work text: %w", err)
} }
// Store results in database // Store results in database
if err := s.analysisRepo.StoreAnalysisResults(ctx, workID, result); err != nil { if err := s.analysisRepo.StoreAnalysisResults(ctx, workID, result); err != nil {
log.LogError("Failed to store analysis results", logger.Error(err, "Failed to store analysis results")
log.F("workID", workID),
log.F("error", err))
return fmt.Errorf("failed to store analysis results: %w", err) return fmt.Errorf("failed to store analysis results: %w", err)
} }
@ -145,18 +136,15 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro
if s.cacheEnabled && s.analysisCache.IsEnabled() { if s.cacheEnabled && s.analysisCache.IsEnabled() {
cacheKey := fmt.Sprintf("work_analysis:%d", workID) cacheKey := fmt.Sprintf("work_analysis:%d", workID)
if err := s.analysisCache.Set(ctx, cacheKey, result); err != nil { if err := s.analysisCache.Set(ctx, cacheKey, result); err != nil {
log.LogWarn("Failed to cache work analysis result", logger.Error(err, "Failed to cache work analysis result")
log.F("workID", workID),
log.F("error", err))
} }
} }
log.LogInfo("Successfully analyzed work", logger.With("wordCount", result.WordCount).
log.F("workID", workID), With("readabilityScore", result.ReadabilityScore).
log.F("wordCount", result.WordCount), With("sentiment", result.Sentiment).
log.F("readabilityScore", result.ReadabilityScore), With("durationMs", time.Since(start).Milliseconds()).
log.F("sentiment", result.Sentiment), Info("Successfully analyzed work")
log.F("durationMs", time.Since(start).Milliseconds()))
return nil return nil
} }

View File

@ -51,4 +51,10 @@ func (l *Logger) Ctx(ctx context.Context) *Logger {
} }
// `log` is now the correct *zerolog.Logger, so we wrap it. // `log` is now the correct *zerolog.Logger, so we wrap it.
return &Logger{log} return &Logger{log}
}
// With adds a key-value pair to the logger's context.
func (l *Logger) With(key string, value interface{}) *Logger {
newLogger := l.Logger.With().Interface(key, value).Logger()
return &Logger{&newLogger}
} }

View File

@ -11,8 +11,10 @@ import (
// Metrics contains the Prometheus metrics for the application. // Metrics contains the Prometheus metrics for the application.
type Metrics struct { type Metrics struct {
RequestsTotal *prometheus.CounterVec RequestsTotal *prometheus.CounterVec
RequestDuration *prometheus.HistogramVec RequestDuration *prometheus.HistogramVec
DBQueriesTotal *prometheus.CounterVec
DBQueryDuration *prometheus.HistogramVec
} }
// NewMetrics creates and registers the Prometheus metrics. // NewMetrics creates and registers the Prometheus metrics.
@ -33,6 +35,21 @@ func NewMetrics(reg prometheus.Registerer) *Metrics {
}, },
[]string{"method", "path"}, []string{"method", "path"},
), ),
DBQueriesTotal: promauto.With(reg).NewCounterVec(
prometheus.CounterOpts{
Name: "db_queries_total",
Help: "Total number of database queries.",
},
[]string{"operation", "status"},
),
DBQueryDuration: promauto.With(reg).NewHistogramVec(
prometheus.HistogramOpts{
Name: "db_query_duration_seconds",
Help: "Duration of database queries.",
Buckets: prometheus.DefBuckets,
},
[]string{"operation", "status"},
),
} }
} }

View File

@ -12,9 +12,15 @@ import (
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
) )
type contextKey string // ContextKey is the type for context keys to avoid collisions.
type ContextKey string
const RequestIDKey contextKey = "request_id" const (
// RequestIDKey is the key for the request ID in the context.
RequestIDKey ContextKey = "request_id"
// LoggerContextKey is the key for the logger in the context.
LoggerContextKey ContextKey = "logger"
)
// responseWriter is a wrapper around http.ResponseWriter to capture the status code. // responseWriter is a wrapper around http.ResponseWriter to capture the status code.
type responseWriter struct { type responseWriter struct {
@ -37,6 +43,35 @@ func RequestIDMiddleware(next http.Handler) http.Handler {
}) })
} }
// LoggingMiddleware creates a request-scoped logger and injects it into the context.
func LoggingMiddleware(log *Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Start with a logger that has trace and span IDs.
requestLogger := log.Ctx(r.Context())
// Add request_id to logger context.
if reqID, ok := r.Context().Value(RequestIDKey).(string); ok {
requestLogger = requestLogger.With("request_id", reqID)
}
// Add the logger to the context.
ctx := context.WithValue(r.Context(), LoggerContextKey, requestLogger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// LoggerFromContext retrieves the request-scoped logger from the context.
// If no logger is found, it returns a default logger.
func LoggerFromContext(ctx context.Context) *Logger {
if logger, ok := ctx.Value(LoggerContextKey).(*Logger); ok {
return logger
}
// Fallback to a default logger if none is found in context.
return NewLogger("tercul-fallback", "development")
}
// TracingMiddleware creates a new OpenTelemetry span for each request. // TracingMiddleware creates a new OpenTelemetry span for each request.
func TracingMiddleware(next http.Handler) http.Handler { func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -4,7 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"strings" "strings"
"tercul/internal/observability"
"tercul/internal/platform/log" "tercul/internal/platform/log"
) )
@ -22,6 +22,7 @@ const (
func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := log.FromContext(r.Context())
// Skip authentication for certain paths // Skip authentication for certain paths
if shouldSkipAuth(r.URL.Path) { if shouldSkipAuth(r.URL.Path) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@ -32,9 +33,7 @@ func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
if err != nil { if err != nil {
log.LogWarn("Authentication failed - missing or invalid token", logger.Warn("Authentication failed - missing or invalid token")
log.F("path", r.URL.Path),
log.F("error", err))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -42,9 +41,7 @@ func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
// Validate token // Validate token
claims, err := jwtManager.ValidateToken(tokenString) claims, err := jwtManager.ValidateToken(tokenString)
if err != nil { if err != nil {
log.LogWarn("Authentication failed - invalid token", logger.Warn("Authentication failed - invalid token")
log.F("path", r.URL.Path),
log.F("error", err))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -60,21 +57,17 @@ func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler { func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := log.FromContext(r.Context())
claims, ok := r.Context().Value(ClaimsContextKey).(*Claims) claims, ok := r.Context().Value(ClaimsContextKey).(*Claims)
if !ok { if !ok {
log.LogWarn("Authorization failed - no claims in context", logger.Warn("Authorization failed - no claims in context")
log.F("path", r.URL.Path),
log.F("required_role", requiredRole))
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
jwtManager := NewJWTManager() jwtManager := NewJWTManager()
if err := jwtManager.RequireRole(claims.Role, requiredRole); err != nil { if err := jwtManager.RequireRole(claims.Role, requiredRole); err != nil {
log.LogWarn("Authorization failed - insufficient role", logger.With("user_role", claims.Role).With("required_role", requiredRole).Warn("Authorization failed - insufficient role")
log.F("path", r.URL.Path),
log.F("user_role", claims.Role),
log.F("required_role", requiredRole))
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@ -88,6 +81,7 @@ func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler {
func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := log.FromContext(r.Context())
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@ -96,20 +90,22 @@ func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handl
tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader)
if err != nil { if err != nil {
log.LogWarn("GraphQL authentication failed - could not extract token", log.F("error", err)) logger.Error(err, "GraphQL authentication failed - could not extract token")
next.ServeHTTP(w, r) // Proceed without auth next.ServeHTTP(w, r) // Proceed without auth
return return
} }
claims, err := jwtManager.ValidateToken(tokenString) claims, err := jwtManager.ValidateToken(tokenString)
if err != nil { if err != nil {
log.LogWarn("GraphQL authentication failed - invalid token", log.F("error", err)) logger.Error(err, "GraphQL authentication failed - invalid token")
next.ServeHTTP(w, r) // Proceed without auth next.ServeHTTP(w, r) // Proceed without auth
return return
} }
// Add claims to context for authenticated requests // Add claims and enriched logger to context for authenticated requests
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims) ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
enrichedLogger := logger.With("user_id", claims.UserID)
ctx = context.WithValue(ctx, observability.LoggerContextKey, enrichedLogger)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

@ -112,9 +112,7 @@ func (c *RedisCache) GetMulti(ctx context.Context, keys []string) (map[string][]
str, ok := values[i].(string) str, ok := values[i].(string)
if !ok { if !ok {
log.LogWarn("Invalid type in Redis cache", log.FromContext(ctx).With("key", key).With("type", fmt.Sprintf("%T", values[i])).Warn("Invalid type in Redis cache")
log.F("key", key),
log.F("type", fmt.Sprintf("%T", values[i])))
continue continue
} }

View File

@ -2,6 +2,7 @@ package db
import ( import (
"fmt" "fmt"
"tercul/internal/observability"
"time" "time"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
@ -16,10 +17,8 @@ var DB *gorm.DB
// Connect establishes a connection to the database using configuration settings // Connect establishes a connection to the database using configuration settings
// It returns the database connection and any error encountered // It returns the database connection and any error encountered
func Connect() (*gorm.DB, error) { func Connect(metrics *observability.Metrics) (*gorm.DB, error) {
log.LogInfo("Connecting to database", log.Info(fmt.Sprintf("Connecting to database: host=%s db=%s", config.Cfg.DBHost, config.Cfg.DBName))
log.F("host", config.Cfg.DBHost),
log.F("database", config.Cfg.DBName))
dsn := config.Cfg.GetDSN() dsn := config.Cfg.GetDSN()
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
@ -29,6 +28,11 @@ func Connect() (*gorm.DB, error) {
return nil, fmt.Errorf("failed to connect to database: %w", err) return nil, fmt.Errorf("failed to connect to database: %w", err)
} }
// Register Prometheus plugin
if err := db.Use(NewPrometheusPlugin(metrics)); err != nil {
return nil, fmt.Errorf("failed to register prometheus plugin: %w", err)
}
// Set the global DB instance // Set the global DB instance
DB = db DB = db
@ -43,9 +47,7 @@ func Connect() (*gorm.DB, error) {
sqlDB.SetMaxIdleConns(5) // Idle connections sqlDB.SetMaxIdleConns(5) // Idle connections
sqlDB.SetConnMaxLifetime(30 * time.Minute) sqlDB.SetConnMaxLifetime(30 * time.Minute)
log.LogInfo("Successfully connected to database", log.Info(fmt.Sprintf("Successfully connected to database: host=%s db=%s", config.Cfg.DBHost, config.Cfg.DBName))
log.F("host", config.Cfg.DBHost),
log.F("database", config.Cfg.DBName))
return db, nil return db, nil
} }
@ -66,9 +68,9 @@ func Close() error {
// InitDB initializes the database connection and runs migrations // InitDB initializes the database connection and runs migrations
// It returns the database connection and any error encountered // It returns the database connection and any error encountered
func InitDB() (*gorm.DB, error) { func InitDB(metrics *observability.Metrics) (*gorm.DB, error) {
// Connect to the database // Connect to the database
db, err := Connect() db, err := Connect(metrics)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,76 @@
package db
import (
"tercul/internal/observability"
"time"
"gorm.io/gorm"
)
const (
callBackBeforeName = "prometheus:before"
callBackAfterName = "prometheus:after"
startTime = "start_time"
)
type PrometheusPlugin struct {
Metrics *observability.Metrics
}
func (p *PrometheusPlugin) Name() string {
return "PrometheusPlugin"
}
func (p *PrometheusPlugin) Initialize(db *gorm.DB) error {
// Before callbacks
db.Callback().Create().Before("gorm:create").Register(callBackBeforeName, p.before)
db.Callback().Query().Before("gorm:query").Register(callBackBeforeName, p.before)
db.Callback().Update().Before("gorm:update").Register(callBackBeforeName, p.before)
db.Callback().Delete().Before("gorm:delete").Register(callBackBeforeName, p.before)
db.Callback().Row().Before("gorm:row").Register(callBackBeforeName, p.before)
db.Callback().Raw().Before("gorm:raw").Register(callBackBeforeName, p.before)
// After callbacks
db.Callback().Create().After("gorm:create").Register(callBackAfterName, p.after)
db.Callback().Query().After("gorm:query").Register(callBackAfterName, p.after)
db.Callback().Update().After("gorm:update").Register(callBackAfterName, p.after)
db.Callback().Delete().After("gorm:delete").Register(callBackAfterName, p.after)
db.Callback().Row().After("gorm:row").Register(callBackAfterName, p.after)
db.Callback().Raw().After("gorm:raw").Register(callBackAfterName, p.after)
return nil
}
func (p *PrometheusPlugin) before(db *gorm.DB) {
db.Set(startTime, time.Now())
}
func (p *PrometheusPlugin) after(db *gorm.DB) {
_ts, ok := db.Get(startTime)
if !ok {
return
}
ts, ok := _ts.(time.Time)
if !ok {
return
}
operation := db.Statement.SQL.String()
if len(operation) > 50 { // Truncate long queries
operation = operation[:50]
}
status := "success"
if db.Error != nil {
status = "error"
}
duration := time.Since(ts).Seconds()
p.Metrics.DBQueryDuration.WithLabelValues(operation, status).Observe(duration)
p.Metrics.DBQueriesTotal.WithLabelValues(operation, status).Inc()
}
func NewPrometheusPlugin(metrics *observability.Metrics) *PrometheusPlugin {
return &PrometheusPlugin{Metrics: metrics}
}

View File

@ -85,9 +85,9 @@ func RateLimitMiddleware(next http.Handler) http.Handler {
// Check if request is allowed // Check if request is allowed
if !rateLimiter.Allow(clientID) { if !rateLimiter.Allow(clientID) {
log.LogWarn("Rate limit exceeded", log.FromContext(r.Context()).
log.F("clientID", clientID), With("clientID", clientID).
log.F("path", r.URL.Path)) Warn("Rate limit exceeded")
w.WriteHeader(http.StatusTooManyRequests) w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("Rate limit exceeded. Please try again later.")) w.Write([]byte("Rate limit exceeded. Please try again later."))

View File

@ -8,232 +8,96 @@ import (
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
// LogLevel represents the severity level of a log message. // Logger is a wrapper around the observability logger.
type LogLevel int
const (
// DebugLevel for detailed troubleshooting.
DebugLevel LogLevel = iota
// InfoLevel for general operational information.
InfoLevel
// WarnLevel for potentially harmful situations.
WarnLevel
// ErrorLevel for error events that might still allow the application to continue.
ErrorLevel
// FatalLevel for severe error events that will lead the application to abort.
FatalLevel
)
// Field represents a key-value pair for structured logging.
type Field struct {
Key string
Value interface{}
}
// F creates a new Field.
func F(key string, value interface{}) Field {
return Field{Key: key, Value: value}
}
// Logger provides structured logging capabilities.
type Logger struct { type Logger struct {
*observability.Logger *observability.Logger
} }
var defaultLogger = &Logger{observability.NewLogger("tercul", "development")} // defaultLogger is the global fallback logger.
var defaultLogger = observability.NewLogger("tercul", "development")
// Init re-initializes the default logger. This is useful for applications // Init re-initializes the default logger. This is useful for applications
// that need to configure the logger with dynamic values. // that need to configure the logger with dynamic values from config.
func Init(serviceName, environment string) { func Init(serviceName, environment string) {
defaultLogger = &Logger{observability.NewLogger(serviceName, environment)} defaultLogger = observability.NewLogger(serviceName, environment)
} }
// SetDefaultLevel sets the log level for the default logger. // FromContext retrieves the request-scoped logger from the context.
func SetDefaultLevel(level LogLevel) { // If no logger is found, it returns the default global logger.
var zlevel zerolog.Level func FromContext(ctx context.Context) *Logger {
switch level { // We wrap the observability.Logger in our platform.Logger
case DebugLevel: return &Logger{observability.LoggerFromContext(ctx)}
zlevel = zerolog.DebugLevel
case InfoLevel:
zlevel = zerolog.InfoLevel
case WarnLevel:
zlevel = zerolog.WarnLevel
case ErrorLevel:
zlevel = zerolog.ErrorLevel
case FatalLevel:
zlevel = zerolog.FatalLevel
default:
zlevel = zerolog.InfoLevel
}
zerolog.SetGlobalLevel(zlevel)
} }
func log(level LogLevel, msg string, fields ...Field) { // SetLevel sets the global log level.
var event *zerolog.Event func SetLevel(level zerolog.Level) {
// Access the embedded observability.Logger to get to zerolog's methods. zerolog.SetGlobalLevel(level)
zlog := defaultLogger.Logger
switch level {
case DebugLevel:
event = zlog.Debug()
case InfoLevel:
event = zlog.Info()
case WarnLevel:
event = zlog.Warn()
case ErrorLevel:
event = zlog.Error()
case FatalLevel:
event = zlog.Fatal()
default:
event = zlog.Info()
}
for _, f := range fields {
event.Interface(f.Key, f.Value)
}
event.Msg(msg)
}
// LogDebug logs a message at debug level using the default logger.
func LogDebug(msg string, fields ...Field) {
log(DebugLevel, msg, fields...)
}
// LogInfo logs a message at info level using the default logger.
func LogInfo(msg string, fields ...Field) {
log(InfoLevel, msg, fields...)
}
// LogWarn logs a message at warn level using the default logger.
func LogWarn(msg string, fields ...Field) {
log(WarnLevel, msg, fields...)
}
// LogError logs a message at error level using the default logger.
func LogError(msg string, fields ...Field) {
log(ErrorLevel, msg, fields...)
}
// LogFatal logs a message at fatal level using the default logger and then calls os.Exit(1).
func LogFatal(msg string, fields ...Field) {
log(FatalLevel, msg, fields...)
}
// WithFields returns a new logger with the given fields added using the default logger.
func WithFields(fields ...Field) *Logger {
sublogger := defaultLogger.With().Logger()
for _, f := range fields {
sublogger = sublogger.With().Interface(f.Key, f.Value).Logger()
}
return &Logger{&observability.Logger{&sublogger}}
}
// WithContext returns a new logger with the given context added using the default logger.
func WithContext(ctx context.Context) *Logger {
return &Logger{defaultLogger.Ctx(ctx)}
}
// The following functions are kept for compatibility but are now simplified or deprecated.
// SetDefaultLogger is deprecated. Use Init.
func SetDefaultLogger(logger *Logger) {
// Deprecated: Logger is now initialized via Init.
}
// String returns the string representation of the log level.
func (l LogLevel) String() string {
switch l {
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case WarnLevel:
return "WARN"
case ErrorLevel:
return "ERROR"
case FatalLevel:
return "FATAL"
default:
return "UNKNOWN"
}
} }
// Debug logs a message at debug level. // Debug logs a message at debug level.
func (l *Logger) Debug(msg string, fields ...Field) { func (l *Logger) Debug(msg string) {
l.log(DebugLevel, msg, fields...) l.Logger.Debug().Msg(msg)
} }
// Info logs a message at info level. // Info logs a message at info level.
func (l *Logger) Info(msg string, fields ...Field) { func (l *Logger) Info(msg string) {
l.log(InfoLevel, msg, fields...) l.Logger.Info().Msg(msg)
} }
// Warn logs a message at warn level. // Warn logs a message at warn level.
func (l *Logger) Warn(msg string, fields ...Field) { func (l *Logger) Warn(msg string) {
l.log(WarnLevel, msg, fields...) l.Logger.Warn().Msg(msg)
} }
// Error logs a message at error level. // Error logs a message at error level.
func (l *Logger) Error(msg string, fields ...Field) { func (l *Logger) Error(err error, msg string) {
l.log(ErrorLevel, msg, fields...) l.Logger.Error().Err(err).Msg(msg)
} }
// Fatal logs a message at fatal level and then calls os.Exit(1). // Fatal logs a message at fatal level and then calls os.Exit(1).
func (l *Logger) Fatal(msg string, fields ...Field) { func (l *Logger) Fatal(err error, msg string) {
l.log(FatalLevel, msg, fields...) l.Logger.Fatal().Err(err).Msg(msg)
} }
func (l *Logger) log(level LogLevel, msg string, fields ...Field) { // With adds a key-value pair to the logger's context.
var event *zerolog.Event func (l *Logger) With(key string, value interface{}) *Logger {
switch level { return &Logger{l.Logger.With(key, value)}
case DebugLevel:
event = l.Logger.Debug()
case InfoLevel:
event = l.Logger.Info()
case WarnLevel:
event = l.Logger.Warn()
case ErrorLevel:
event = l.Logger.Error()
case FatalLevel:
event = l.Logger.Fatal()
default:
event = l.Logger.Info()
}
for _, f := range fields {
event.Interface(f.Key, f.Value)
}
event.Msg(msg)
} }
// WithFields returns a new logger with the given fields added. // Infof logs a formatted message at info level.
func (l *Logger) WithFields(fields ...Field) *Logger { func (l *Logger) Infof(format string, v ...interface{}) {
sublogger := l.With().Logger() l.Info(fmt.Sprintf(format, v...))
for _, f := range fields {
sublogger = sublogger.With().Interface(f.Key, f.Value).Logger()
}
return &Logger{&observability.Logger{&sublogger}}
} }
func (l *Logger) WithContext(ctx map[string]interface{}) *Logger { // Errorf logs a formatted message at error level.
// To maintain compatibility with the old API, we will convert the map to a context. func (l *Logger) Errorf(err error, format string, v ...interface{}) {
// This is not ideal and should be refactored in the future. l.Error(err, fmt.Sprintf(format, v...))
zlog := l.Logger.With().Logger()
for k, v := range ctx {
zlog = zlog.With().Interface(k, v).Logger()
}
return &Logger{&observability.Logger{&zlog}}
} }
func (l *Logger) SetLevel(level LogLevel) { // The following functions use the default logger and are kept for convenience
// This now controls the global log level. // in areas where a context is not available.
SetDefaultLevel(level)
// Debug logs a message at debug level using the default logger.
func Debug(msg string) {
defaultLogger.Debug().Msg(msg)
} }
// Fmt versions for simple string formatting // Info logs a message at info level using the default logger.
func LogInfof(format string, v ...interface{}) { func Info(msg string) {
log(InfoLevel, fmt.Sprintf(format, v...)) defaultLogger.Info().Msg(msg)
} }
func LogErrorf(format string, v ...interface{}) { // Warn logs a message at warn level using the default logger.
log(ErrorLevel, fmt.Sprintf(format, v...)) func Warn(msg string) {
defaultLogger.Warn().Msg(msg)
}
// Error logs a message at error level using the default logger.
func Error(err error, msg string) {
defaultLogger.Error().Err(err).Msg(msg)
}
// Fatal logs a message at fatal level using the default logger.
func Fatal(err error, msg string) {
defaultLogger.Fatal().Err(err).Msg(msg)
} }

View File

@ -0,0 +1,60 @@
package openlibrary
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const baseURL = "https://openlibrary.org"
// Client is a client for the Open Library API.
type Client struct {
httpClient *http.Client
}
// NewClient creates a new Open Library client.
func NewClient() *Client {
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Author represents the data returned from the Open Library Authors API.
type Author struct {
Name string `json:"name"`
PersonalName string `json:"personal_name"`
Bio interface{} `json:"bio"` // Bio can be a string or a struct
Wikipedia string `json:"wikipedia"`
}
// GetAuthor fetches author data from the Open Library API.
// The olid is the Open Library Author ID (e.g., "OL23919A").
func (c *Client) GetAuthor(ctx context.Context, olid string) (*Author, error) {
url := fmt.Sprintf("%s/authors/%s.json", baseURL, olid)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "TerculEnrichmentTool/1.0 (contact@tercul.com)")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var author Author
if err := json.NewDecoder(resp.Body).Decode(&author); err != nil {
return nil, err
}
return &author, nil
}

View File

@ -35,7 +35,7 @@ func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*doma
return &cp, nil return &cp, nil
} }
} }
return nil, ErrEntityNotFound return nil, domain.ErrEntityNotFound
} }
func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Translation) error { func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Translation) error {
@ -45,7 +45,7 @@ func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Transl
return nil return nil
} }
} }
return ErrEntityNotFound return domain.ErrEntityNotFound
} }
func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error { func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error {
@ -55,7 +55,7 @@ func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error {
return nil return nil
} }
} }
return ErrEntityNotFound return domain.ErrEntityNotFound
} }
func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) {

View File

@ -1,93 +1,41 @@
package testutil package testutil
import ( import (
"database/sql"
"errors"
"fmt"
"log"
"os" "os"
"testing" "testing"
"time"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"tercul/internal/platform/config"
) )
var ErrEntityNotFound = errors.New("entity not found") // BaseSuite is a base test suite with common functionality for tests.
// It is designed for unit and mock-based integration tests and does not
// TestDB holds the test database connection // handle database connections.
var TestDB *gorm.DB type BaseSuite struct {
suite.Suite
// SetupTestDB sets up a test database connection
func SetupTestDB() (*gorm.DB, error) {
// Load configuration
config.LoadConfig()
// Use test-specific environment variables if available, otherwise fall back to main config
host := getEnv("TEST_DB_HOST", config.Cfg.DBHost)
port := getEnv("TEST_DB_PORT", config.Cfg.DBPort)
user := getEnv("TEST_DB_USER", config.Cfg.DBUser)
password := getEnv("TEST_DB_PASSWORD", config.Cfg.DBPassword)
dbname := getEnv("TEST_DB_NAME", "tercul_test") // Always use test database
sslmode := getEnv("TEST_DB_SSLMODE", config.Cfg.DBSSLMode)
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
host, port, user, password, dbname, sslmode)
// Custom logger for tests
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Silent, // Silent during tests
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
return nil, fmt.Errorf("failed to connect to test database: %w", err)
}
// Set connection pool settings
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get SQL DB instance: %w", err)
}
sqlDB.SetMaxOpenConns(5)
sqlDB.SetMaxIdleConns(2)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
} }
// TruncateTables truncates all tables in the test database // SetupSuite can be overridden by specific test suites for setup.
func TruncateTables(db *gorm.DB, tables ...string) error { func (s *BaseSuite) SetupSuite() {
for _, table := range tables { // No-op by default.
if err := db.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)).Error; err != nil {
return err
}
}
return nil
} }
// CloseDB closes the test database connection // TearDownSuite can be overridden by specific test suites for teardown.
func CloseDB(db *gorm.DB) error { func (s *BaseSuite) TearDownSuite() {
sqlDB, err := db.DB() // No-op by default.
if err != nil {
return err
}
return sqlDB.Close()
} }
// getEnv gets an environment variable or returns a default value // SetupTest can be overridden by specific test suites for per-test setup.
func (s *BaseSuite) SetupTest() {
// No-op by default.
}
// TearDownTest can be overridden by specific test suites for per-test teardown.
func (s *BaseSuite) TearDownTest() {
// No-op by default.
}
// getEnv gets an environment variable or returns a default value.
// This is kept as a general utility function.
func getEnv(key, defaultValue string) string { func getEnv(key, defaultValue string) string {
value, exists := os.LookupEnv(key) value, exists := os.LookupEnv(key)
if !exists { if !exists {
@ -96,63 +44,9 @@ func getEnv(key, defaultValue string) string {
return value return value
} }
// BaseSuite is a base test suite with common functionality // SkipIfShort skips a test if the -short flag is provided.
// For integration tests using mocks, DB is not used
// TODO: Remove DB logic for mock-based integration tests (priority: high, effort: medium)
type BaseSuite struct {
suite.Suite
// DB *gorm.DB // Removed for mock-based integration tests
}
// SetupSuite sets up the test suite
func (s *BaseSuite) SetupSuite() {
// No DB setup for mock-based integration tests
}
// TearDownSuite tears down the test suite
func (s *BaseSuite) TearDownSuite() {
// No DB teardown for mock-based integration tests
}
// SetupTest sets up each test
func (s *BaseSuite) SetupTest() {
// Can be overridden by specific test suites
}
// TearDownTest tears down each test
func (s *BaseSuite) TearDownTest() {
// Can be overridden by specific test suites
}
// RunTransactional runs a test function in a transaction
// TODO: Remove or refactor for mock-based tests (priority: low, effort: low)
func (s *BaseSuite) RunTransactional(testFunc func(tx interface{})) {
// No-op for mock-based tests
}
// MockDB creates a mock database for testing
func MockDB() (*sql.DB, error) {
// Use environment variables for test database connection
host := getEnv("TEST_DB_HOST", "localhost")
port := getEnv("TEST_DB_PORT", "5432")
user := getEnv("TEST_DB_USER", "postgres")
password := getEnv("TEST_DB_PASSWORD", "postgres")
dbname := getEnv("TEST_DB_NAME", "tercul_test")
sslmode := getEnv("TEST_DB_SSLMODE", "disable")
dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
user, password, host, port, dbname, sslmode)
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
return db, nil
}
// SkipIfShort skips a test if the -short flag is provided
func SkipIfShort(t *testing.T) { func SkipIfShort(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("Skipping test in short mode") t.Skip("Skipping test in short mode")
} }
} }