diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ac05ba --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# The Tercul Project + +Welcome to Tercul, a modern platform for literary enthusiasts to discover, translate, and discuss works from around the world. This repository contains the backend services, API, and data processing pipelines that power the platform. + +## Architecture + +The Tercul backend is built using a Domain-Driven Design (DDD-lite) approach, emphasizing a clean separation of concerns between domain logic, application services, and infrastructure. Key architectural patterns include: + +- **Command Query Responsibility Segregation (CQRS):** Application logic is separated into Commands (for writing data) and Queries (for reading data). This allows for optimized, scalable, and maintainable services. +- **Clean Architecture:** Dependencies flow inwards, with inner layers (domain) having no knowledge of outer layers (infrastructure). +- **Dependency Injection:** Services and repositories are instantiated at the application's entry point (`cmd/api/main.go`) and injected as dependencies, promoting loose coupling and testability. + +For a more detailed explanation of the architectural vision and ongoing refactoring efforts, please see `refactor.md`. + +## Getting Started + +Follow these instructions to get the development environment up and running on your local machine. + +### Prerequisites + +- **Go:** Version 1.25.0 (as specified in `.tool-versions`) +- **Docker & Docker Compose:** For running external dependencies like PostgreSQL and Weaviate. +- **make:** For running common development commands. +- **golangci-lint:** For running the linter. Install it with: + ```bash + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + ``` + Ensure your Go binary path (`$(go env GOPATH)/bin`) is in your shell's `PATH`. + +### Installation + +1. **Clone the repository:** + ```bash + git clone + cd tercul + ``` + +2. **Install Go dependencies:** + ```bash + go mod tidy + ``` + +### Configuration + +The application is configured using environment variables. A local `docker-compose.yml` file is provided to run the necessary services (PostgreSQL, Weaviate, etc.) with default development settings. + +The application will automatically connect to these services. For a full list of configurable variables and their default values, see `internal/platform/config/config.go`. + +## Running the Application + +1. **Start external services:** + ```bash + docker-compose up -d + ``` + +2. **Run the API server:** + ```bash + go run cmd/api/main.go + ``` + The API server will be available at `http://localhost:8080`. The GraphQL playground can be accessed at `http://localhost:8080/playground`. + +## Running Tests + +To ensure code quality and correctness, run the full suite of linters and tests: + +```bash +make lint-test +``` + +This command executes the same checks that are run in our Continuous Integration (CI) pipeline. \ No newline at end of file diff --git a/api/README.md b/api/README.md index 29c145f..56de061 100644 --- a/api/README.md +++ b/api/README.md @@ -7,19 +7,11 @@ This document provides comprehensive documentation for the Tercul GraphQL API. I - [Introduction](#introduction) - [Authentication](#authentication) - [Queries](#queries) - - [Work](#work) - - [Translation](#translation) - - [Author](#author) - - [User](#user) - - [Collection](#collection) - - [Utility](#utility) - [Mutations](#mutations) - - [Authentication](#authentication-mutations) - - [Work](#work-mutations) - - [Translation](#translation-mutations) - - [Like](#like-mutations) - - [Comment](#comment-mutations) - [Core Types](#core-types) +- [Input Types](#input-types) +- [Enums](#enums) +- [Scalars](#scalars) ## Introduction @@ -38,97 +30,47 @@ Most mutations and some queries require authentication. To authenticate, you mus This section details all available queries. -### Work +### Work Queries +- **`work(id: ID!): Work`**: Retrieves a single work by its unique ID. +- **`works(limit: Int, offset: Int, language: String, authorId: ID, categoryId: ID, tagId: ID, search: String): [Work!]!`**: Retrieves a list of works with optional filters and pagination. -#### `work(id: ID!): Work` -Retrieves a single work by its unique ID. -- **`id`**: The ID of the work to retrieve. +### Translation Queries +- **`translation(id: ID!): Translation`**: Retrieves a single translation by its unique ID. +- **`translations(workId: ID!, language: String, limit: Int, offset: Int): [Translation!]!`**: Retrieves a list of translations for a given work. -*Example:* -```graphql -query GetWork { - work(id: "1") { - id - name - language - content - stats { - likes - views - } - } -} -``` +### Book Queries +- **`book(id: ID!): Book`**: Retrieves a single book by ID. +- **`books(limit: Int, offset: Int): [Book!]!`**: Retrieves a list of all books. -#### `works(...)` -Retrieves a list of works, with optional filters. -- **`limit`**: The maximum number of works to return. -- **`offset`**: The number of works to skip for pagination. -- **`language`**: Filter works by language code (e.g., "en"). -- **`authorId`**: Filter works by author ID. -- **`categoryId`**: Filter works by category ID. -- **`tagId`**: Filter works by tag ID. -- **`search`**: A string to search for in work titles and content. +### Author Queries +- **`author(id: ID!): Author`**: Retrieves a single author by ID. +- **`authors(limit: Int, offset: Int, search: String, countryId: ID): [Author!]!`**: Retrieves a list of authors with optional filters. -*Example:* -```graphql -query GetWorks { - works(limit: 10, language: "en") { - id - name - } -} -``` +### User Queries +- **`user(id: ID!): User`**: Retrieves a single user by ID. +- **`userByEmail(email: String!): User`**: Retrieves a single user by email address. +- **`userByUsername(username: String!): User`**: Retrieves a single user by username. +- **`users(limit: Int, offset: Int, role: UserRole): [User!]!`**: Retrieves a list of users, with optional role filter. +- **`me: User`**: Retrieves the currently authenticated user. +- **`userProfile(userId: ID!): UserProfile`**: Retrieves the profile for a given user. -### Translation +### Collection Queries +- **`collection(id: ID!): Collection`**: Retrieves a single collection by ID. +- **`collections(userId: ID, limit: Int, offset: Int): [Collection!]!`**: Retrieves a list of collections, optionally filtered by user. -#### `translation(id: ID!): Translation` -Retrieves a single translation by its unique ID. -- **`id`**: The ID of the translation. +### Tag & Category Queries +- **`tag(id: ID!): Tag`**: Retrieves a single tag by ID. +- **`tags(limit: Int, offset: Int): [Tag!]!`**: Retrieves a list of all tags. +- **`category(id: ID!): Category`**: Retrieves a single category by ID. +- **`categories(limit: Int, offset: Int): [Category!]!`**: Retrieves a list of all categories. -#### `translations(...)` -Retrieves a list of translations for a given work. -- **`workId`**: The ID of the parent work. -- **`language`**: Filter translations by language code. -- **`limit`**: The maximum number of translations to return. -- **`offset`**: The number of translations to skip. +### Comment Queries +- **`comment(id: ID!): Comment`**: Retrieves a single comment by ID. +- **`comments(workId: ID, translationId: ID, userId: ID, limit: Int, offset: Int): [Comment!]!`**: Retrieves a list of comments with optional filters. -### Author - -#### `author(id: ID!): Author` -Retrieves a single author by ID. - -#### `authors(...)` -Retrieves a list of authors with optional filters. - -### User - -#### `user(id: ID!): User` -Retrieves a single user by ID. - -#### `userByEmail(email: String!): User` -Retrieves a single user by email address. - -#### `userByUsername(username: String!): User` -Retrieves a single user by username. - -#### `me: User` -Retrieves the currently authenticated user. - -### Collection - -#### `collection(id: ID!): Collection` -Retrieves a single collection by ID. - -#### `collections(...)` -Retrieves a list of collections, optionally filtered by user. - -### Utility - -#### `trendingWorks(...)` -Returns a list of works with the highest engagement. -- **`timePeriod`**: "daily", "weekly", or "monthly". Defaults to "daily". -- **`limit`**: The number of works to return. Defaults to 10. +### Utility Queries +- **`search(query: String!, limit: Int, offset: Int, filters: SearchFilters): SearchResults!`**: Performs a sitewide search across works, translations, and authors. +- **`trendingWorks(timePeriod: String, limit: Int): [Work!]!`**: Returns a list of works with the highest engagement. `timePeriod` can be "daily", "weekly", or "monthly". --- @@ -136,82 +78,134 @@ Returns a list of works with the highest engagement. This section details all available mutations for creating, updating, and deleting data. -### Authentication Mutations +### Authentication +- **`register(input: RegisterInput!): AuthPayload!`**: Creates a new user account. +- **`login(input: LoginInput!): AuthPayload!`**: Authenticates a user and returns a JWT. +- **`logout: Boolean!`**: Logs out the currently authenticated user. +- **`refreshToken: AuthPayload!`**: Issues a new JWT for an active session. +- **`forgotPassword(email: String!): Boolean!`**: Initiates the password reset process for a user. +- **`resetPassword(token: String!, newPassword: String!): Boolean!`**: Resets a user's password using a valid token. +- **`verifyEmail(token: String!): Boolean!`**: Verifies a user's email address using a token. +- **`resendVerificationEmail(email: String!): Boolean!`**: Resends the email verification link. -#### `register(input: RegisterInput!): AuthPayload!` -Creates a new user account. +### User & Profile +- **`updateUser(id: ID!, input: UserInput!): User!`**: Updates a user's details (Admin). +- **`deleteUser(id: ID!): Boolean!`**: Deletes a user account (Admin). +- **`updateProfile(input: UserInput!): User!`**: Allows a user to update their own profile information. +- **`changePassword(currentPassword: String!, newPassword: String!): Boolean!`**: Allows an authenticated user to change their password. -#### `login(input: LoginInput!): AuthPayload!` -Authenticates a user and returns a JWT. +### Work +- **`createWork(input: WorkInput!): Work!`**: Creates a new work. +- **`updateWork(id: ID!, input: WorkInput!): Work!`**: Updates an existing work. +- **`deleteWork(id: ID!): Boolean!`**: Deletes a work. -#### `logout: Boolean!` -Logs out the currently authenticated user. +### Translation +- **`createTranslation(input: TranslationInput!): Translation!`**: Creates a new translation for a work. +- **`updateTranslation(id: ID!, input: TranslationInput!): Translation!`**: Updates an existing translation. +- **`deleteTranslation(id: ID!): Boolean!`**: Deletes a translation. -#### `refreshToken: AuthPayload!` -Issues a new JWT for an active session. +### Book +- **`createBook(input: BookInput!): Book!`**: Creates a new book. +- **`updateBook(id: ID!, input: BookInput!): Book!`**: Updates an existing book. +- **`deleteBook(id: ID!): Boolean!`**: Deletes a book. -### Work Mutations +### Author +- **`createAuthor(input: AuthorInput!): Author!`**: Creates a new author. +- **`updateAuthor(id: ID!, input: AuthorInput!): Author!`**: Updates an existing author. +- **`deleteAuthor(id: ID!): Boolean!`**: Deletes an author. -#### `createWork(input: WorkInput!): Work!` -Creates a new work. +### Collection +- **`createCollection(input: CollectionInput!): Collection!`**: Creates a new collection. +- **`updateCollection(id: ID!, input: CollectionInput!): Collection!`**: Updates an existing collection. +- **`deleteCollection(id: ID!): Boolean!`**: Deletes a collection. +- **`addWorkToCollection(collectionId: ID!, workId: ID!): Collection!`**: Adds a work to a collection. +- **`removeWorkFromCollection(collectionId: ID!, workId: ID!): Collection!`**: Removes a work from a collection. -#### `updateWork(id: ID!, input: WorkInput!): Work!` -Updates an existing work. +### Comment +- **`createComment(input: CommentInput!): Comment!`**: Adds a comment to a work or translation. +- **`updateComment(id: ID!, input: CommentInput!): Comment!`**: Updates an existing comment. +- **`deleteComment(id: ID!): Boolean!`**: Deletes a comment. -#### `deleteWork(id: ID!): Boolean!` -Deletes a work. +### Like +- **`createLike(input: LikeInput!): Like!`**: Adds a like to a work, translation, or comment. +- **`deleteLike(id: ID!): Boolean!`**: Removes a previously added like. -### Translation Mutations +### Bookmark +- **`createBookmark(input: BookmarkInput!): Bookmark!`**: Creates a bookmark for a work. +- **`deleteBookmark(id: ID!): Boolean!`**: Deletes a bookmark. -#### `createTranslation(input: TranslationInput!): Translation!` -Creates a new translation for a work. - -#### `updateTranslation(id: ID!, input: TranslationInput!): Translation!` -Updates an existing translation. - -#### `deleteTranslation(id: ID!): Boolean!` -Deletes a translation. - -### Like Mutations - -#### `createLike(input: LikeInput!): Like!` -Adds a like to a work, translation, or comment. The input must contain exactly one of `workId`, `translationId`, or `commentId`. - -*Example:* -```graphql -mutation LikeWork { - createLike(input: { workId: "123" }) { - id - user { - id - } - } -} -``` - -#### `deleteLike(id: ID!): Boolean!` -Removes a previously added like. - -### Comment Mutations - -#### `createComment(input: CommentInput!): Comment!` -Adds a comment to a work or translation. - -#### `updateComment(id: ID!, input: CommentInput!): Comment!` -Updates an existing comment. - -#### `deleteComment(id: ID!): Boolean!` -Deletes a comment. +### Contribution +- **`createContribution(input: ContributionInput!): Contribution!`**: Creates a new contribution. +- **`updateContribution(id: ID!, input: ContributionInput!): Contribution!`**: Updates a contribution. +- **`deleteContribution(id: ID!): Boolean!`**: Deletes a contribution. +- **`reviewContribution(id: ID!, status: ContributionStatus!, feedback: String): Contribution!`**: Reviews a contribution, setting its status and providing feedback. --- ## Core Types -- **`Work`**: Represents a literary work. +This section describes the main data structures in the API. + +- **`Work`**: Represents a literary work. Contains fields for content, metadata, and associations to authors, translations, etc. - **`Translation`**: Represents a translation of a `Work`. - **`Author`**: Represents the creator of a `Work`. - **`User`**: Represents a platform user. -- **`Comment`**: Represents a comment on a `Work` or `Translation`. -- **`Like`**: Represents a like on a `Work`, `Translation`, or `Comment`. +- **`UserProfile`**: Contains extended profile information for a `User`. +- **`Book`**: Represents a book, which can contain multiple works. - **`Collection`**: Represents a user-curated collection of works. -- **`WorkStats` / `TranslationStats`**: Represents analytics data for content. \ No newline at end of file +- **`Tag`**: A keyword or label associated with a `Work`. +- **`Category`**: A classification for a `Work`. +- **`Comment`**: A user-submitted comment on a `Work` or `Translation`. +- **`Like`**: Represents a "like" action from a user on a `Work`, `Translation`, or `Comment`. +- **`Bookmark`**: A user's personal bookmark for a `Work`. +- **`Contribution`**: Represents a user's submission of a new work or translation. +- **`ReadabilityScore`**: Flesch-Kincaid or similar readability score for a `Work`. +- **`WritingStyle`**: Analysis of the writing style of a `Work`. +- **`Emotion`**: Emotional analysis result for a piece of content. +- **`TopicCluster`**: A cluster of related topics for a `Work`. +- **`Mood`**: The detected mood of a `Work`. +- **`Concept`**: A key concept extracted from a `Work`. +- **`Word`**: A specific word linked to a `Concept`. +- **`LinguisticLayer`**: Detailed linguistic analysis of a `Work`. +- **`WorkStats` / `TranslationStats` / etc.**: Analytics data (views, likes, etc.) for various entities. +- **`TextMetadata`**: General metadata from text analysis. +- **`PoeticAnalysis`**: Specific analysis of poetic structure. +- **`Copyright` / `CopyrightClaim`**: Information related to copyright ownership and claims. +- **`Country` / `City` / `Place` / `Address`**: Location-related data for users and authors. +- **`Source`**: The original source of a work or translation. +- **`Edge`**: A generic graph edge for representing relationships between entities. +- **`SearchResults`**: A container for results from the `search` query. +- **`AuthPayload`**: A container for the authentication token and user object returned upon login/registration. + +--- + +## Input Types + +This section describes the input objects used in mutations. + +- **`LoginInput`**: Contains `email` and `password` for the `login` mutation. +- **`RegisterInput`**: Contains `username`, `email`, `password`, `firstName`, and `lastName` for the `register` mutation. +- **`WorkInput`**: Used for creating/updating a `Work`. +- **`TranslationInput`**: Used for creating/updating a `Translation`. +- **`AuthorInput`**: Used for creating/updating an `Author`. +- **`BookInput`**: Used for creating/updating a `Book`. +- **`UserInput`**: Used for updating user data. +- **`CollectionInput`**: Used for creating/updating a `Collection`. +- **`CommentInput`**: Used for creating/updating a `Comment`. +- **`LikeInput`**: Specifies the entity to be liked (`workId`, `translationId`, or `commentId`). +- **`BookmarkInput`**: Used for creating a `Bookmark`. +- **`ContributionInput`**: Used for creating/updating a `Contribution`. +- **`SearchFilters`**: A set of optional filters for the `search` query. + +--- + +## Enums + +- **`UserRole`**: Defines the possible roles for a user (`READER`, `CONTRIBUTOR`, `REVIEWER`, `EDITOR`, `ADMIN`). +- **`ContributionStatus`**: Defines the lifecycle status of a contribution (`DRAFT`, `SUBMITTED`, `UNDER_REVIEW`, `APPROVED`, `REJECTED`). + +--- + +## Scalars + +- **`JSON`**: A standard JSON object. Used for flexible data fields like user preferences. \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go index c4ccc5a..406eeda 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -146,7 +146,7 @@ func main() { translationService := translation.NewService(repos.Translation, authzService) userService := user.NewService(repos.User, authzService, repos.UserProfile) authService := auth.NewService(repos.User, jwtManager) - workService := work.NewService(repos.Work, searchClient, authzService) + workService := work.NewService(repos.Work, searchClient, authzService, analyticsService) // Create application application := app.NewApplication( diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index ef2c19f..0237761 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -1320,11 +1320,11 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran return nil, fmt.Errorf("invalid translation ID: %v", err) } - translationRecord, err := r.App.Translation.Queries.Translation(ctx, uint(translationID)) + translationDTO, err := r.App.Translation.Queries.Translation(ctx, uint(translationID)) if err != nil { return nil, err } - if translationRecord == nil { + if translationDTO == nil { return nil, nil } @@ -1336,10 +1336,10 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran return &model.Translation{ ID: id, - Name: translationRecord.Title, - Language: translationRecord.Language, - Content: &translationRecord.Content, - WorkID: fmt.Sprintf("%d", translationRecord.TranslatableID), + Name: translationDTO.Title, + Language: translationDTO.Language, + Content: &translationDTO.Content, + WorkID: fmt.Sprintf("%d", translationDTO.TranslatableID), }, nil } diff --git a/internal/app/analytics/README.md b/internal/app/analytics/README.md new file mode 100644 index 0000000..bcb303b --- /dev/null +++ b/internal/app/analytics/README.md @@ -0,0 +1,57 @@ +# Analytics Service + +This package is responsible for collecting, processing, and retrieving all analytical data for the Tercul platform. It handles statistics for works, translations, and user engagement. + +## Architecture Overview + +The analytics service provides a central point for all statistical operations. It is designed to be called by other application services (e.g., after a user likes a work) to increment or decrement counters. It also provides methods for more complex analytical tasks, such as calculating reading time and sentiment scores. + +### Key Components + +- **`service.go`**: The main entry point for the analytics service. It implements the `Service` interface and contains the core business logic for all analytical operations. +- **`interfaces.go`**: Defines the `Service` and `Repository` interfaces, establishing a clear contract for the service's capabilities and its data persistence requirements. +- **Repository (external)**: The service relies on an `AnalyticsRepository`, implemented in the `internal/data/sql` package, to interact with the database. + +## Features + +- **Counter Management**: Provides methods to increment and decrement statistics like views, likes, comments, and bookmarks for works and translations. +- **Content Analysis**: Calculates and updates metrics such as: + - Reading time + - Complexity (via readability scores) + - Sentiment analysis +- **User Engagement Tracking**: Monitors user activities like works read, comments made, and likes given. +- **Trending System**: Calculates and stores trending works based on a scoring algorithm that considers views, likes, and comments. + +## Usage + +The `analytics.Service` is intended to be injected into other application services that need to record analytical events. + +### Example: Incrementing Work Likes + +```go +// In another application service (e.g., the 'like' service) +err := analyticsService.IncrementWorkLikes(ctx, workID) +``` + +### Example: Updating Work Analysis + +```go +// In a command handler (e.g., work.AnalyzeWork) +err := analyticsService.UpdateWorkReadingTime(ctx, workID) +if err != nil { + // handle error +} + +err = analyticsService.UpdateWorkComplexity(ctx, workID) +if err != nil { + // handle error +} +``` + +## Dependencies + +- **`internal/domain`**: Uses the core domain entities (e.g., `WorkStats`, `TranslationStats`, `Trending`). +- **`internal/jobs/linguistics`**: Relies on the `linguistics` package for analysis data like readability scores and sentiment. +- **Database**: Persists all statistical data to the main application database via the `AnalyticsRepository`. +- **Logging**: Uses the centralized logger from `internal/platform/log`. +- **OpenTelemetry**: All service methods are instrumented for distributed tracing. \ No newline at end of file diff --git a/internal/app/auth/README.md b/internal/app/auth/README.md new file mode 100644 index 0000000..95a495a --- /dev/null +++ b/internal/app/auth/README.md @@ -0,0 +1,52 @@ +# Auth Service + +This package handles all user authentication and session management for the Tercul platform. It is responsible for registering new users, authenticating existing users, and managing JSON Web Tokens (JWTs). + +## Architecture Overview + +The auth service is designed to be a self-contained unit for all authentication-related logic. It provides a clear API for other parts of the application to interact with user sessions. + +### Key Components + +- **`service.go`**: The main entry point for the auth service. It implements the `Service` interface and contains the core business logic for registration, login, logout, and token management. + +- **`commands.go`**: Contains the command handlers for all authentication-related actions, such as: + - `Register`: Creates a new user account. + - `Login`: Authenticates a user and issues a JWT. + - `Logout`: Invalidates a user's session. + - `RefreshToken`: Issues a new JWT for an active session. + - `ForgotPassword` / `ResetPassword`: Handles the password reset flow. + - `VerifyEmail` / `ResendVerificationEmail`: Manages email verification. + - `ChangePassword`: Allows an authenticated user to change their password. + +- **`interfaces.go`**: Defines the `Service` and `AuthRepository` interfaces, establishing a clear contract for the service's capabilities and its data persistence requirements. + +- **`jwt.go` (in `internal/platform/auth`)**: The service relies on the `JWTManager` from this platform package to handle the creation and validation of JWTs. + +## Usage + +The `auth.Service` is primarily used by the GraphQL resolvers to handle authentication-related mutations. + +### Example: User Registration + +```go +// In a GraphQL resolver +registerInput := auth.RegisterInput{...} +authResponse, err := authService.Commands.Register(ctx, registerInput) +``` + +### Example: User Login + +```go +// In a GraphQL resolver +loginInput := auth.LoginInput{...} +authResponse, err := authService.Commands.Login(ctx, loginInput) +``` + +## Dependencies + +- **`internal/domain`**: Uses the core `User` domain entity. +- **`internal/platform/auth`**: Relies on the `JWTManager` to handle all JWT operations. This is a critical dependency for session management. +- **Database**: Persists user data via the `UserRepository`. +- **Logging**: Uses the centralized logger from `internal/platform/log`. +- **OpenTelemetry**: All service and command methods are instrumented for distributed tracing. \ No newline at end of file diff --git a/internal/app/translation/dto.go b/internal/app/translation/dto.go new file mode 100644 index 0000000..32e01c4 --- /dev/null +++ b/internal/app/translation/dto.go @@ -0,0 +1,10 @@ +package translation + +// TranslationDTO is a read model for a translation. +type TranslationDTO struct { + ID uint + Title string + Language string + Content string + TranslatableID uint +} \ No newline at end of file diff --git a/internal/app/translation/mock_repository_test.go b/internal/app/translation/mock_repository_test.go new file mode 100644 index 0000000..7620b55 --- /dev/null +++ b/internal/app/translation/mock_repository_test.go @@ -0,0 +1,95 @@ +package translation + +import ( + "context" + "tercul/internal/domain" + "gorm.io/gorm" +) + +type mockTranslationRepository struct { + domain.TranslationRepository + getByIDFunc func(ctx context.Context, id uint) (*domain.Translation, error) + listByWorkIDPaginatedFunc func(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) +} + +func (m *mockTranslationRepository) GetByID(ctx context.Context, id uint) (*domain.Translation, error) { + if m.getByIDFunc != nil { + return m.getByIDFunc(ctx, id) + } + return nil, nil +} + +func (m *mockTranslationRepository) ListByWorkIDPaginated(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + if m.listByWorkIDPaginatedFunc != nil { + return m.listByWorkIDPaginatedFunc(ctx, workID, language, page, pageSize) + } + return nil, nil +} + +// Implement other methods of the interface if needed for tests, returning nil or default values. +func (m *mockTranslationRepository) Create(ctx context.Context, entity *domain.Translation) error { + return nil +} +func (m *mockTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error { + return nil +} +func (m *mockTranslationRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) Update(ctx context.Context, entity *domain.Translation) error { + return nil +} +func (m *mockTranslationRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error { + return nil +} +func (m *mockTranslationRepository) Delete(ctx context.Context, id uint) error { + return nil +} +func (m *mockTranslationRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return nil +} +func (m *mockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + return nil, nil +} +func (m *mockTranslationRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) ListAll(ctx context.Context) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) Count(ctx context.Context) (int64, error) { + return 0, nil +} +func (m *mockTranslationRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + return 0, nil +} +func (m *mockTranslationRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) Exists(ctx context.Context, id uint) (bool, error) { + return false, nil +} +func (m *mockTranslationRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} +func (m *mockTranslationRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return nil +} +func (m *mockTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { + return nil, nil +} +func (m *mockTranslationRepository) Upsert(ctx context.Context, translation *domain.Translation) error { + return nil +} \ No newline at end of file diff --git a/internal/app/translation/queries.go b/internal/app/translation/queries.go index 4e2243c..be7ed80 100644 --- a/internal/app/translation/queries.go +++ b/internal/app/translation/queries.go @@ -23,10 +23,25 @@ func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQuerie } // 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) (*TranslationDTO, error) { ctx, span := q.tracer.Start(ctx, "Translation") defer span.End() - return q.repo.GetByID(ctx, id) + + translation, err := q.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if translation == nil { + return nil, nil + } + + return &TranslationDTO{ + ID: translation.ID, + Title: translation.Title, + Language: translation.Language, + Content: translation.Content, + TranslatableID: translation.TranslatableID, + }, nil } // TranslationsByWorkID returns all translations for a work. @@ -65,8 +80,31 @@ func (q *TranslationQueries) Translations(ctx context.Context) ([]domain.Transla } // ListTranslations returns a paginated list of translations for a work, with optional language filtering. -func (q *TranslationQueries) ListTranslations(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { +func (q *TranslationQueries) ListTranslations(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[TranslationDTO], error) { ctx, span := q.tracer.Start(ctx, "ListTranslations") defer span.End() - return q.repo.ListByWorkIDPaginated(ctx, workID, language, page, pageSize) + + paginatedTranslations, err := q.repo.ListByWorkIDPaginated(ctx, workID, language, page, pageSize) + if err != nil { + return nil, err + } + + translationDTOs := make([]TranslationDTO, len(paginatedTranslations.Items)) + for i, t := range paginatedTranslations.Items { + translationDTOs[i] = TranslationDTO{ + ID: t.ID, + Title: t.Title, + Language: t.Language, + Content: t.Content, + TranslatableID: t.TranslatableID, + } + } + + return &domain.PaginatedResult[TranslationDTO]{ + Items: translationDTOs, + TotalCount: paginatedTranslations.TotalCount, + Page: paginatedTranslations.Page, + PageSize: paginatedTranslations.PageSize, + TotalPages: paginatedTranslations.TotalPages, + }, nil } diff --git a/internal/app/translation/queries_test.go b/internal/app/translation/queries_test.go new file mode 100644 index 0000000..ebb0859 --- /dev/null +++ b/internal/app/translation/queries_test.go @@ -0,0 +1,79 @@ +package translation + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "tercul/internal/domain" + "testing" +) + +type TranslationQueriesSuite struct { + suite.Suite + repo *mockTranslationRepository + queries *TranslationQueries +} + +func (s *TranslationQueriesSuite) SetupTest() { + s.repo = &mockTranslationRepository{} + s.queries = NewTranslationQueries(s.repo) +} + +func TestTranslationQueriesSuite(t *testing.T) { + suite.Run(t, new(TranslationQueriesSuite)) +} + +func (s *TranslationQueriesSuite) TestTranslation_Success() { + translation := &domain.Translation{ + BaseModel: domain.BaseModel{ID: 1}, + Title: "Test Translation", + Language: "es", + Content: "Test content", + TranslatableID: 1, + } + s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Translation, error) { + return translation, nil + } + dto, err := s.queries.Translation(context.Background(), 1) + assert.NoError(s.T(), err) + + expectedDTO := &TranslationDTO{ + ID: 1, + Title: "Test Translation", + Language: "es", + Content: "Test content", + TranslatableID: 1, + } + assert.Equal(s.T(), expectedDTO, dto) +} + +func (s *TranslationQueriesSuite) TestListTranslations_Success() { + domainTranslations := &domain.PaginatedResult[domain.Translation]{ + Items: []domain.Translation{ + {BaseModel: domain.BaseModel{ID: 1}, Title: "Translation 1", Language: "es", Content: "Content 1", TranslatableID: 1}, + {BaseModel: domain.BaseModel{ID: 2}, Title: "Translation 2", Language: "fr", Content: "Content 2", TranslatableID: 1}, + }, + TotalCount: 2, + Page: 1, + PageSize: 10, + TotalPages: 1, + } + s.repo.listByWorkIDPaginatedFunc = func(ctx context.Context, workID uint, language *string, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { + return domainTranslations, nil + } + + paginatedDTOs, err := s.queries.ListTranslations(context.Background(), 1, nil, 1, 10) + assert.NoError(s.T(), err) + + expectedDTOs := &domain.PaginatedResult[TranslationDTO]{ + Items: []TranslationDTO{ + {ID: 1, Title: "Translation 1", Language: "es", Content: "Content 1", TranslatableID: 1}, + {ID: 2, Title: "Translation 2", Language: "fr", Content: "Content 2", TranslatableID: 1}, + }, + TotalCount: 2, + Page: 1, + PageSize: 10, + TotalPages: 1, + } + assert.Equal(s.T(), expectedDTOs, paginatedDTOs) +} \ No newline at end of file diff --git a/internal/app/work/README.md b/internal/app/work/README.md new file mode 100644 index 0000000..9e97f5d --- /dev/null +++ b/internal/app/work/README.md @@ -0,0 +1,52 @@ +# Work Service + +This package manages the core `Work` domain entity in the Tercul platform. It provides the primary application service for all operations related to literary works, including creating, updating, deleting, and retrieving them. + +## Architecture Overview + +The work service is designed following the Command Query Responsibility Segregation (CQRS) pattern. This separation makes the service more maintainable, scalable, and easier to understand. + +### Key Components + +- **`service.go`**: The main entry point for the work service. It composes the `WorkCommands` and `WorkQueries` into a single `Service` struct, which is then used by the rest of the application (e.g., in the GraphQL resolvers). + +- **`commands.go`**: Contains all the command handlers for mutating `Work` entities. This includes: + - `CreateWork`: Creates a new work. + - `UpdateWork`: Updates an existing work. + - `DeleteWork`: Deletes a work. + - `AnalyzeWork`: Triggers a comprehensive linguistic analysis of a work and its translations. + - `MergeWork`: Merges two works into one, consolidating their translations and statistics. + +- **`queries.go`**: Contains all the query handlers for retrieving `Work` data. These handlers return specialized `WorkDTO` read models to ensure a clean separation between the domain and the API layer. + +- **`dto.go`**: Defines the `WorkDTO` struct, which is the read model used for all query responses. + +- **`interfaces.go` (external)**: The service depends on the `WorkRepository` interface defined in `internal/domain/interfaces.go` for data persistence. + +## Usage + +The `work.Service` is intended to be injected into the application's top-level layers, such as the GraphQL resolvers, which then call the appropriate command or query handlers. + +### Example: Creating a Work + +```go +// In a GraphQL resolver +work, err := workService.Commands.CreateWork(ctx, &domain.Work{...}) +``` + +### Example: Retrieving a Work + +```go +// In a GraphQL resolver +workDTO, err := workService.Queries.GetWorkByID(ctx, workID) +``` + +## Dependencies + +- **`internal/domain`**: Uses and returns the core `Work` domain entity and the `WorkDTO` read model. +- **`internal/app/authz`**: Relies on the authorization service to perform permission checks before executing commands like `UpdateWork` and `DeleteWork`. +- **`internal/app/analytics`**: The `AnalyzeWork` command calls the analytics service to perform and store linguistic analysis. +- **`internal/domain/search`**: Uses the `SearchClient` interface to index works in the search engine after they are created or updated. +- **Database**: Persists all `Work` data via the `WorkRepository`. +- **Logging**: Uses the centralized logger from `internal/platform/log`. +- **OpenTelemetry**: All command and query methods are instrumented for distributed tracing. \ No newline at end of file diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index 8142c80..0279d81 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "tercul/internal/app/analytics" "tercul/internal/app/authz" "tercul/internal/domain" "tercul/internal/domain/search" @@ -20,15 +21,17 @@ type WorkCommands struct { repo domain.WorkRepository searchClient search.SearchClient authzSvc *authz.Service + analyticsSvc analytics.Service tracer trace.Tracer } // NewWorkCommands creates a new WorkCommands handler. -func NewWorkCommands(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands { +func NewWorkCommands(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service, analyticsSvc analytics.Service) *WorkCommands { return &WorkCommands{ repo: repo, searchClient: searchClient, authzSvc: authzSvc, + analyticsSvc: analyticsSvc, tracer: otel.Tracer("work.commands"), } } @@ -140,11 +143,42 @@ func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error { return c.repo.Delete(ctx, id) } -// AnalyzeWork performs linguistic analysis on a work. +// AnalyzeWork performs linguistic analysis on a work and its translations. func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error { - _, span := c.tracer.Start(ctx, "AnalyzeWork") + ctx, span := c.tracer.Start(ctx, "AnalyzeWork") defer span.End() - // TODO: implement this + logger := log.FromContext(ctx).With("workID", workID) + + work, err := c.repo.GetWithTranslations(ctx, workID) + if err != nil { + return fmt.Errorf("failed to get work for analysis: %w", err) + } + + logger.Info("Starting analysis for work") + + // Analyze the parent work's metadata. + if err := c.analyticsSvc.UpdateWorkReadingTime(ctx, workID); err != nil { + logger.Error(err, "failed to update work reading time") + } + if err := c.analyticsSvc.UpdateWorkComplexity(ctx, workID); err != nil { + logger.Error(err, "failed to update work complexity") + } + if err := c.analyticsSvc.UpdateWorkSentiment(ctx, workID); err != nil { + logger.Error(err, "failed to update work sentiment") + } + + // Analyze each translation. + for _, translation := range work.Translations { + logger.Info(fmt.Sprintf("Analyzing translation %d", translation.ID)) + if err := c.analyticsSvc.UpdateTranslationReadingTime(ctx, translation.ID); err != nil { + logger.Error(err, fmt.Sprintf("failed to update translation reading time for translation %d", translation.ID)) + } + if err := c.analyticsSvc.UpdateTranslationSentiment(ctx, translation.ID); err != nil { + logger.Error(err, fmt.Sprintf("failed to update translation sentiment for translation %d", translation.ID)) + } + } + + logger.Info("Finished analysis for work") return nil } diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index 0f3c289..cdb6a2b 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -21,6 +21,7 @@ type WorkCommandsSuite struct { repo *mockWorkRepository searchClient *mockSearchClient authzSvc *authz.Service + analyticsSvc *mockAnalyticsService commands *WorkCommands } @@ -28,7 +29,8 @@ func (s *WorkCommandsSuite) SetupTest() { s.repo = &mockWorkRepository{} s.searchClient = &mockSearchClient{} s.authzSvc = authz.NewService(s.repo, nil) - s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc) + s.analyticsSvc = &mockAnalyticsService{} + s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc, s.analyticsSvc) } func TestWorkCommandsSuite(t *testing.T) { @@ -148,8 +150,47 @@ func (s *WorkCommandsSuite) TestDeleteWork_RepoError() { } func (s *WorkCommandsSuite) TestAnalyzeWork_Success() { + work := &domain.Work{ + TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, + Translations: []*domain.Translation{ + {BaseModel: domain.BaseModel{ID: 101}}, + {BaseModel: domain.BaseModel{ID: 102}}, + }, + } + s.repo.getWithTranslationsFunc = func(ctx context.Context, id uint) (*domain.Work, error) { + return work, nil + } + + var readingTime, complexity, sentiment, tReadingTime, tSentiment int + s.analyticsSvc.updateWorkReadingTimeFunc = func(ctx context.Context, workID uint) error { + readingTime++ + return nil + } + s.analyticsSvc.updateWorkComplexityFunc = func(ctx context.Context, workID uint) error { + complexity++ + return nil + } + s.analyticsSvc.updateWorkSentimentFunc = func(ctx context.Context, workID uint) error { + sentiment++ + return nil + } + s.analyticsSvc.updateTranslationReadingTimeFunc = func(ctx context.Context, translationID uint) error { + tReadingTime++ + return nil + } + s.analyticsSvc.updateTranslationSentimentFunc = func(ctx context.Context, translationID uint) error { + tSentiment++ + return nil + } + err := s.commands.AnalyzeWork(context.Background(), 1) assert.NoError(s.T(), err) + + assert.Equal(s.T(), 1, readingTime, "UpdateWorkReadingTime should be called once") + assert.Equal(s.T(), 1, complexity, "UpdateWorkComplexity should be called once") + assert.Equal(s.T(), 1, sentiment, "UpdateWorkSentiment should be called once") + assert.Equal(s.T(), 2, tReadingTime, "UpdateTranslationReadingTime should be called for each translation") + assert.Equal(s.T(), 2, tSentiment, "UpdateTranslationSentiment should be called for each translation") } func TestMergeWork_Integration(t *testing.T) { @@ -177,7 +218,8 @@ func TestMergeWork_Integration(t *testing.T) { workRepo := sql.NewWorkRepository(db, cfg) authzSvc := authz.NewService(workRepo, nil) // Using real repo for authz checks searchClient := &mockSearchClient{} // Mock search client is fine - commands := NewWorkCommands(workRepo, searchClient, authzSvc) + analyticsSvc := &mockAnalyticsService{} + commands := NewWorkCommands(workRepo, searchClient, authzSvc, analyticsSvc) // --- Seed Data --- author1 := &domain.Author{Name: "Author One"} diff --git a/internal/app/work/dto.go b/internal/app/work/dto.go new file mode 100644 index 0000000..af3be66 --- /dev/null +++ b/internal/app/work/dto.go @@ -0,0 +1,8 @@ +package work + +// WorkDTO is a read model for a work, containing only the data needed for API responses. +type WorkDTO struct { + ID uint + Title string + Language string +} \ No newline at end of file diff --git a/internal/app/work/mock_analytics_service_test.go b/internal/app/work/mock_analytics_service_test.go new file mode 100644 index 0000000..559111e --- /dev/null +++ b/internal/app/work/mock_analytics_service_test.go @@ -0,0 +1,92 @@ +package work + +import ( + "context" + "tercul/internal/domain" +) + +type mockAnalyticsService struct { + updateWorkReadingTimeFunc func(ctx context.Context, workID uint) error + updateWorkComplexityFunc func(ctx context.Context, workID uint) error + updateWorkSentimentFunc func(ctx context.Context, workID uint) error + updateTranslationReadingTimeFunc func(ctx context.Context, translationID uint) error + updateTranslationSentimentFunc func(ctx context.Context, translationID uint) error +} + +func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { + if m.updateWorkReadingTimeFunc != nil { + return m.updateWorkReadingTimeFunc(ctx, workID) + } + return nil +} + +func (m *mockAnalyticsService) UpdateWorkComplexity(ctx context.Context, workID uint) error { + if m.updateWorkComplexityFunc != nil { + return m.updateWorkComplexityFunc(ctx, workID) + } + return nil +} + +func (m *mockAnalyticsService) UpdateWorkSentiment(ctx context.Context, workID uint) error { + if m.updateWorkSentimentFunc != nil { + return m.updateWorkSentimentFunc(ctx, workID) + } + return nil +} + +func (m *mockAnalyticsService) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + if m.updateTranslationReadingTimeFunc != nil { + return m.updateTranslationReadingTimeFunc(ctx, translationID) + } + return nil +} + +func (m *mockAnalyticsService) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + if m.updateTranslationSentimentFunc != nil { + return m.updateTranslationSentimentFunc(ctx, translationID) + } + return nil +} + +// Implement other methods of the analytics.Service interface to satisfy the compiler +func (m *mockAnalyticsService) IncrementWorkViews(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkShares(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationViews(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) DecrementWorkLikes(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) DecrementTranslationLikes(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + return nil, nil +} +func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + return nil, nil +} +func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + return nil +} +func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil } +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + return nil, nil +} \ No newline at end of file diff --git a/internal/app/work/queries.go b/internal/app/work/queries.go index 9f08ab8..61b5a78 100644 --- a/internal/app/work/queries.go +++ b/internal/app/work/queries.go @@ -24,20 +24,54 @@ func NewWorkQueries(repo domain.WorkRepository) *WorkQueries { } // GetWorkByID retrieves a work by ID. -func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*domain.Work, error) { +func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*WorkDTO, error) { ctx, span := q.tracer.Start(ctx, "GetWorkByID") defer span.End() if id == 0 { return nil, errors.New("invalid work ID") } - return q.repo.GetByID(ctx, id) + + work, err := q.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if work == nil { + return nil, nil + } + + return &WorkDTO{ + ID: work.ID, + Title: work.Title, + Language: work.Language, + }, nil } // ListWorks returns a paginated list of works. -func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { +func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[WorkDTO], error) { ctx, span := q.tracer.Start(ctx, "ListWorks") defer span.End() - return q.repo.List(ctx, page, pageSize) + + paginatedWorks, err := q.repo.List(ctx, page, pageSize) + if err != nil { + return nil, err + } + + workDTOs := make([]WorkDTO, len(paginatedWorks.Items)) + for i, work := range paginatedWorks.Items { + workDTOs[i] = WorkDTO{ + ID: work.ID, + Title: work.Title, + Language: work.Language, + } + } + + return &domain.PaginatedResult[WorkDTO]{ + Items: workDTOs, + TotalCount: paginatedWorks.TotalCount, + Page: paginatedWorks.Page, + PageSize: paginatedWorks.PageSize, + TotalPages: paginatedWorks.TotalPages, + }, nil } // GetWorkWithTranslations retrieves a work with its translations. diff --git a/internal/app/work/queries_test.go b/internal/app/work/queries_test.go index 3f14630..5817edc 100644 --- a/internal/app/work/queries_test.go +++ b/internal/app/work/queries_test.go @@ -31,7 +31,12 @@ func (s *WorkQueriesSuite) TestGetWorkByID_Success() { } w, err := s.queries.GetWorkByID(context.Background(), 1) assert.NoError(s.T(), err) - assert.Equal(s.T(), work, w) + expectedDTO := &WorkDTO{ + ID: work.ID, + Title: work.Title, + Language: work.Language, + } + assert.Equal(s.T(), expectedDTO, w) } func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { @@ -41,13 +46,34 @@ func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { } func (s *WorkQueriesSuite) TestListWorks_Success() { - works := &domain.PaginatedResult[domain.Work]{} - s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { - return works, nil + domainWorks := &domain.PaginatedResult[domain.Work]{ + Items: []domain.Work{ + {TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}, Language: "en"}, Title: "Work 1"}, + {TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 2}, Language: "fr"}, Title: "Work 2"}, + }, + TotalCount: 2, + Page: 1, + PageSize: 10, + TotalPages: 1, } - w, err := s.queries.ListWorks(context.Background(), 1, 10) + s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + return domainWorks, nil + } + + paginatedDTOs, err := s.queries.ListWorks(context.Background(), 1, 10) assert.NoError(s.T(), err) - assert.Equal(s.T(), works, w) + + expectedDTOs := &domain.PaginatedResult[WorkDTO]{ + Items: []WorkDTO{ + {ID: 1, Title: "Work 1", Language: "en"}, + {ID: 2, Title: "Work 2", Language: "fr"}, + }, + TotalCount: 2, + Page: 1, + PageSize: 10, + TotalPages: 1, + } + assert.Equal(s.T(), expectedDTOs, paginatedDTOs) } func (s *WorkQueriesSuite) TestGetWorkWithTranslations_Success() { diff --git a/internal/app/work/service.go b/internal/app/work/service.go index cec8c1a..e01260c 100644 --- a/internal/app/work/service.go +++ b/internal/app/work/service.go @@ -1,6 +1,7 @@ package work import ( + "tercul/internal/app/analytics" "tercul/internal/app/authz" "tercul/internal/domain" "tercul/internal/domain/search" @@ -13,9 +14,9 @@ type Service struct { } // NewService creates a new work Service. -func NewService(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service { +func NewService(repo domain.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service, analyticsSvc analytics.Service) *Service { return &Service{ - Commands: NewWorkCommands(repo, searchClient, authzSvc), + Commands: NewWorkCommands(repo, searchClient, authzSvc, analyticsSvc), Queries: NewWorkQueries(repo), } } diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go index 194478b..90df343 100644 --- a/internal/platform/config/config.go +++ b/internal/platform/config/config.go @@ -33,33 +33,35 @@ type Config struct { // LoadConfig reads configuration from file or environment variables. func LoadConfig() (*Config, error) { - viper.SetDefault("ENVIRONMENT", "development") - viper.SetDefault("SERVER_PORT", ":8080") - viper.SetDefault("DB_HOST", "localhost") - viper.SetDefault("DB_PORT", "5432") - viper.SetDefault("DB_USER", "user") - viper.SetDefault("DB_PASSWORD", "password") - viper.SetDefault("DB_NAME", "tercul") - viper.SetDefault("JWT_SECRET", "secret") - viper.SetDefault("JWT_EXPIRATION_HOURS", 24) - viper.SetDefault("WEAVIATE_HOST", "localhost:8080") - viper.SetDefault("WEAVIATE_SCHEME", "http") - viper.SetDefault("MIGRATION_PATH", "internal/data/migrations") - viper.SetDefault("REDIS_ADDR", "localhost:6379") - viper.SetDefault("REDIS_PASSWORD", "") - viper.SetDefault("REDIS_DB", 0) - viper.SetDefault("BATCH_SIZE", 100) - viper.SetDefault("RATE_LIMIT", 10) - viper.SetDefault("RATE_LIMIT_BURST", 100) - viper.SetDefault("NLP_MEMORY_CACHE_CAP", 1024) - viper.SetDefault("NLP_REDIS_CACHE_TTL_SECONDS", 3600) - viper.SetDefault("NLP_USE_LINGUA", true) - viper.SetDefault("NLP_USE_TFIDF", true) + v := viper.New() - viper.AutomaticEnv() + v.SetDefault("ENVIRONMENT", "development") + v.SetDefault("SERVER_PORT", ":8080") + v.SetDefault("DB_HOST", "localhost") + v.SetDefault("DB_PORT", "5432") + v.SetDefault("DB_USER", "user") + v.SetDefault("DB_PASSWORD", "password") + v.SetDefault("DB_NAME", "tercul") + v.SetDefault("JWT_SECRET", "secret") + v.SetDefault("JWT_EXPIRATION_HOURS", 24) + v.SetDefault("WEAVIATE_HOST", "localhost:8080") + v.SetDefault("WEAVIATE_SCHEME", "http") + v.SetDefault("MIGRATION_PATH", "internal/data/migrations") + v.SetDefault("REDIS_ADDR", "localhost:6379") + v.SetDefault("REDIS_PASSWORD", "") + v.SetDefault("REDIS_DB", 0) + v.SetDefault("BATCH_SIZE", 100) + v.SetDefault("RATE_LIMIT", 10) + v.SetDefault("RATE_LIMIT_BURST", 100) + v.SetDefault("NLP_MEMORY_CACHE_CAP", 1024) + v.SetDefault("NLP_REDIS_CACHE_TTL_SECONDS", 3600) + v.SetDefault("NLP_USE_LINGUA", true) + v.SetDefault("NLP_USE_TFIDF", true) + + v.AutomaticEnv() var config Config - if err := viper.Unmarshal(&config); err != nil { + if err := v.Unmarshal(&config); err != nil { return nil, err } return &config, nil diff --git a/internal/platform/db/README.md b/internal/platform/db/README.md new file mode 100644 index 0000000..be75bf1 --- /dev/null +++ b/internal/platform/db/README.md @@ -0,0 +1,50 @@ +# Database Platform Package + +This package is responsible for initializing and managing the application's database connection. It provides a centralized and consistent way to configure and access the database. + +## Architecture Overview + +The `db` package abstracts the underlying database technology (GORM) and provides a simple function, `InitDB`, to create a new database connection based on the application's configuration. + +### Key Components + +- **`db.go`**: Contains the `InitDB` function, which is the sole entry point for this package. It takes a `config.Config` object and a `*observability.Metrics` instance, and returns a `*gorm.DB` connection pool. + +## Features + +- **Centralized Configuration**: All database connection settings (host, port, user, password, etc.) are read from the application's central `config` object. +- **Prometheus Integration**: The package integrates with the `gorm-prometheus` plugin to expose GORM metrics (query latency, error rates, etc.) to the application's Prometheus instance. +- **Connection Management**: The `InitDB` function returns a fully configured `*gorm.DB` object, which manages the connection pool. The `Close` function is provided to gracefully close the database connection on application shutdown. + +## Usage + +The `InitDB` function is called once at the application's startup in `cmd/api/main.go`. The resulting `*gorm.DB` object is then passed down to the repository layer. + +### Example: Initializing the Database + +```go +// In cmd/api/main.go +import "tercul/internal/platform/db" + +// ... + +// Initialize database connection +database, err := db.InitDB(cfg, metrics) +if err != nil { + // handle error +} +defer db.Close(database) + +// Pass the 'database' object to the repositories +repos := sql.NewRepositories(database, cfg) + +// ... +``` + +## Dependencies + +- **`internal/platform/config`**: Relies on the `Config` struct for all database connection parameters. +- **`internal/observability`**: Uses the `Metrics` object to register GORM's Prometheus metrics. +- **`gorm.io/driver/postgres`**: The underlying database driver for PostgreSQL. +- **`gorm.io/gorm`**: The GORM library. +- **`gorm.io/plugin/prometheus`**: The GORM plugin for Prometheus integration. \ No newline at end of file diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index e357e48..f2b581f 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -152,7 +152,7 @@ func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) { userService := user.NewService(repos.User, authzService, repos.UserProfile) localizationService := localization.NewService(repos.Localization) authService := app_auth.NewService(repos.User, jwtManager) - workService := work.NewService(repos.Work, searchClient, authzService) + workService := work.NewService(repos.Work, searchClient, authzService, analyticsService) searchService := app_search.NewService(searchClient, localizationService) s.App = app.NewApplication(