Merge pull request #21 from SamyRai/feat/production-readiness-refactor

Refactor Services and Improve Documentation for Production Readiness
This commit is contained in:
Damir Mukimov 2025-10-08 20:09:18 +02:00 committed by GitHub
commit 8a214b90fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 944 additions and 208 deletions

70
README.md Normal file
View File

@ -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 <repository-url>
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.

View File

@ -7,19 +7,11 @@ This document provides comprehensive documentation for the Tercul GraphQL API. I
- [Introduction](#introduction) - [Introduction](#introduction)
- [Authentication](#authentication) - [Authentication](#authentication)
- [Queries](#queries) - [Queries](#queries)
- [Work](#work)
- [Translation](#translation)
- [Author](#author)
- [User](#user)
- [Collection](#collection)
- [Utility](#utility)
- [Mutations](#mutations) - [Mutations](#mutations)
- [Authentication](#authentication-mutations)
- [Work](#work-mutations)
- [Translation](#translation-mutations)
- [Like](#like-mutations)
- [Comment](#comment-mutations)
- [Core Types](#core-types) - [Core Types](#core-types)
- [Input Types](#input-types)
- [Enums](#enums)
- [Scalars](#scalars)
## Introduction ## Introduction
@ -38,97 +30,47 @@ Most mutations and some queries require authentication. To authenticate, you mus
This section details all available queries. 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` ### Translation Queries
Retrieves a single work by its unique ID. - **`translation(id: ID!): Translation`**: Retrieves a single translation by its unique ID.
- **`id`**: The ID of the work to retrieve. - **`translations(workId: ID!, language: String, limit: Int, offset: Int): [Translation!]!`**: Retrieves a list of translations for a given work.
*Example:* ### Book Queries
```graphql - **`book(id: ID!): Book`**: Retrieves a single book by ID.
query GetWork { - **`books(limit: Int, offset: Int): [Book!]!`**: Retrieves a list of all books.
work(id: "1") {
id
name
language
content
stats {
likes
views
}
}
}
```
#### `works(...)` ### Author Queries
Retrieves a list of works, with optional filters. - **`author(id: ID!): Author`**: Retrieves a single author by ID.
- **`limit`**: The maximum number of works to return. - **`authors(limit: Int, offset: Int, search: String, countryId: ID): [Author!]!`**: Retrieves a list of authors with optional filters.
- **`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.
*Example:* ### User Queries
```graphql - **`user(id: ID!): User`**: Retrieves a single user by ID.
query GetWorks { - **`userByEmail(email: String!): User`**: Retrieves a single user by email address.
works(limit: 10, language: "en") { - **`userByUsername(username: String!): User`**: Retrieves a single user by username.
id - **`users(limit: Int, offset: Int, role: UserRole): [User!]!`**: Retrieves a list of users, with optional role filter.
name - **`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` ### Tag & Category Queries
Retrieves a single translation by its unique ID. - **`tag(id: ID!): Tag`**: Retrieves a single tag by ID.
- **`id`**: The ID of the translation. - **`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(...)` ### Comment Queries
Retrieves a list of translations for a given work. - **`comment(id: ID!): Comment`**: Retrieves a single comment by ID.
- **`workId`**: The ID of the parent work. - **`comments(workId: ID, translationId: ID, userId: ID, limit: Int, offset: Int): [Comment!]!`**: Retrieves a list of comments with optional filters.
- **`language`**: Filter translations by language code.
- **`limit`**: The maximum number of translations to return.
- **`offset`**: The number of translations to skip.
### Author ### Utility Queries
- **`search(query: String!, limit: Int, offset: Int, filters: SearchFilters): SearchResults!`**: Performs a sitewide search across works, translations, and authors.
#### `author(id: ID!): Author` - **`trendingWorks(timePeriod: String, limit: Int): [Work!]!`**: Returns a list of works with the highest engagement. `timePeriod` can be "daily", "weekly", or "monthly".
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.
--- ---
@ -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. 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!` ### User & Profile
Creates a new user account. - **`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!` ### Work
Authenticates a user and returns a JWT. - **`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!` ### Translation
Logs out the currently authenticated user. - **`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!` ### Book
Issues a new JWT for an active session. - **`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!` ### Collection
Creates a new work. - **`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!` ### Comment
Updates an existing work. - **`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!` ### Like
Deletes a work. - **`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!` ### Contribution
Creates a new translation for a work. - **`createContribution(input: ContributionInput!): Contribution!`**: Creates a new contribution.
- **`updateContribution(id: ID!, input: ContributionInput!): Contribution!`**: Updates a contribution.
#### `updateTranslation(id: ID!, input: TranslationInput!): Translation!` - **`deleteContribution(id: ID!): Boolean!`**: Deletes a contribution.
Updates an existing translation. - **`reviewContribution(id: ID!, status: ContributionStatus!, feedback: String): Contribution!`**: Reviews a contribution, setting its status and providing feedback.
#### `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.
--- ---
## Core Types ## 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`. - **`Translation`**: Represents a translation of a `Work`.
- **`Author`**: Represents the creator of a `Work`. - **`Author`**: Represents the creator of a `Work`.
- **`User`**: Represents a platform user. - **`User`**: Represents a platform user.
- **`Comment`**: Represents a comment on a `Work` or `Translation`. - **`UserProfile`**: Contains extended profile information for a `User`.
- **`Like`**: Represents a like on a `Work`, `Translation`, or `Comment`. - **`Book`**: Represents a book, which can contain multiple works.
- **`Collection`**: Represents a user-curated collection of works. - **`Collection`**: Represents a user-curated collection of works.
- **`WorkStats` / `TranslationStats`**: Represents analytics data for content. - **`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.

View File

@ -146,7 +146,7 @@ func main() {
translationService := translation.NewService(repos.Translation, authzService) translationService := translation.NewService(repos.Translation, authzService)
userService := user.NewService(repos.User, authzService, repos.UserProfile) userService := user.NewService(repos.User, authzService, repos.UserProfile)
authService := auth.NewService(repos.User, jwtManager) authService := auth.NewService(repos.User, jwtManager)
workService := work.NewService(repos.Work, searchClient, authzService) workService := work.NewService(repos.Work, searchClient, authzService, analyticsService)
// Create application // Create application
application := app.NewApplication( application := app.NewApplication(

View File

@ -1320,11 +1320,11 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran
return nil, fmt.Errorf("invalid translation ID: %v", err) 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 { if err != nil {
return nil, err return nil, err
} }
if translationRecord == nil { if translationDTO == nil {
return nil, nil return nil, nil
} }
@ -1336,10 +1336,10 @@ func (r *queryResolver) Translation(ctx context.Context, id string) (*model.Tran
return &model.Translation{ return &model.Translation{
ID: id, ID: id,
Name: translationRecord.Title, Name: translationDTO.Title,
Language: translationRecord.Language, Language: translationDTO.Language,
Content: &translationRecord.Content, Content: &translationDTO.Content,
WorkID: fmt.Sprintf("%d", translationRecord.TranslatableID), WorkID: fmt.Sprintf("%d", translationDTO.TranslatableID),
}, nil }, nil
} }

View File

@ -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.

View File

@ -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.

View File

@ -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
}

View File

@ -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
}

View File

@ -23,10 +23,25 @@ func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQuerie
} }
// 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) (*TranslationDTO, error) {
ctx, span := q.tracer.Start(ctx, "Translation") ctx, span := q.tracer.Start(ctx, "Translation")
defer span.End() 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. // 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. // 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") ctx, span := q.tracer.Start(ctx, "ListTranslations")
defer span.End() 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
} }

View File

@ -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)
}

View File

@ -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.

View File

@ -4,6 +4,7 @@ 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"
"tercul/internal/domain/search" "tercul/internal/domain/search"
@ -20,15 +21,17 @@ type WorkCommands struct {
repo domain.WorkRepository repo domain.WorkRepository
searchClient search.SearchClient searchClient search.SearchClient
authzSvc *authz.Service authzSvc *authz.Service
analyticsSvc analytics.Service
tracer trace.Tracer tracer trace.Tracer
} }
// NewWorkCommands creates a new WorkCommands handler. // 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{ return &WorkCommands{
repo: repo, repo: repo,
searchClient: searchClient, searchClient: searchClient,
authzSvc: authzSvc, authzSvc: authzSvc,
analyticsSvc: analyticsSvc,
tracer: otel.Tracer("work.commands"), 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) 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 { 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() 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 return nil
} }

View File

@ -21,6 +21,7 @@ type WorkCommandsSuite struct {
repo *mockWorkRepository repo *mockWorkRepository
searchClient *mockSearchClient searchClient *mockSearchClient
authzSvc *authz.Service authzSvc *authz.Service
analyticsSvc *mockAnalyticsService
commands *WorkCommands commands *WorkCommands
} }
@ -28,7 +29,8 @@ func (s *WorkCommandsSuite) SetupTest() {
s.repo = &mockWorkRepository{} s.repo = &mockWorkRepository{}
s.searchClient = &mockSearchClient{} s.searchClient = &mockSearchClient{}
s.authzSvc = authz.NewService(s.repo, nil) 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) { func TestWorkCommandsSuite(t *testing.T) {
@ -148,8 +150,47 @@ func (s *WorkCommandsSuite) TestDeleteWork_RepoError() {
} }
func (s *WorkCommandsSuite) TestAnalyzeWork_Success() { 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) err := s.commands.AnalyzeWork(context.Background(), 1)
assert.NoError(s.T(), err) 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) { func TestMergeWork_Integration(t *testing.T) {
@ -177,7 +218,8 @@ func TestMergeWork_Integration(t *testing.T) {
workRepo := sql.NewWorkRepository(db, cfg) workRepo := sql.NewWorkRepository(db, cfg)
authzSvc := authz.NewService(workRepo, nil) // Using real repo for authz checks authzSvc := authz.NewService(workRepo, nil) // Using real repo for authz checks
searchClient := &mockSearchClient{} // Mock search client is fine searchClient := &mockSearchClient{} // Mock search client is fine
commands := NewWorkCommands(workRepo, searchClient, authzSvc) analyticsSvc := &mockAnalyticsService{}
commands := NewWorkCommands(workRepo, searchClient, authzSvc, analyticsSvc)
// --- Seed Data --- // --- Seed Data ---
author1 := &domain.Author{Name: "Author One"} author1 := &domain.Author{Name: "Author One"}

8
internal/app/work/dto.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -24,20 +24,54 @@ func NewWorkQueries(repo domain.WorkRepository) *WorkQueries {
} }
// GetWorkByID retrieves a work by ID. // 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") ctx, span := q.tracer.Start(ctx, "GetWorkByID")
defer span.End() defer span.End()
if id == 0 { if id == 0 {
return nil, errors.New("invalid work ID") 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. // 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") ctx, span := q.tracer.Start(ctx, "ListWorks")
defer span.End() 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. // GetWorkWithTranslations retrieves a work with its translations.

View File

@ -31,7 +31,12 @@ func (s *WorkQueriesSuite) TestGetWorkByID_Success() {
} }
w, err := s.queries.GetWorkByID(context.Background(), 1) w, err := s.queries.GetWorkByID(context.Background(), 1)
assert.NoError(s.T(), err) 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() { func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() {
@ -41,13 +46,34 @@ func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() {
} }
func (s *WorkQueriesSuite) TestListWorks_Success() { func (s *WorkQueriesSuite) TestListWorks_Success() {
works := &domain.PaginatedResult[domain.Work]{} domainWorks := &domain.PaginatedResult[domain.Work]{
s.repo.listFunc = func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { Items: []domain.Work{
return works, nil {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.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() { func (s *WorkQueriesSuite) TestGetWorkWithTranslations_Success() {

View File

@ -1,6 +1,7 @@
package work package work
import ( import (
"tercul/internal/app/analytics"
"tercul/internal/app/authz" "tercul/internal/app/authz"
"tercul/internal/domain" "tercul/internal/domain"
"tercul/internal/domain/search" "tercul/internal/domain/search"
@ -13,9 +14,9 @@ type Service struct {
} }
// NewService creates a new work Service. // 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{ return &Service{
Commands: NewWorkCommands(repo, searchClient, authzSvc), Commands: NewWorkCommands(repo, searchClient, authzSvc, analyticsSvc),
Queries: NewWorkQueries(repo), Queries: NewWorkQueries(repo),
} }
} }

View File

@ -33,33 +33,35 @@ type Config struct {
// LoadConfig reads configuration from file or environment variables. // LoadConfig reads configuration from file or environment variables.
func LoadConfig() (*Config, error) { func LoadConfig() (*Config, error) {
viper.SetDefault("ENVIRONMENT", "development") v := viper.New()
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)
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 var config Config
if err := viper.Unmarshal(&config); err != nil { if err := v.Unmarshal(&config); err != nil {
return nil, err return nil, err
} }
return &config, nil return &config, nil

View File

@ -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.

View File

@ -152,7 +152,7 @@ func (s *IntegrationTestSuite) SetupSuite(testConfig *TestConfig) {
userService := user.NewService(repos.User, authzService, repos.UserProfile) userService := user.NewService(repos.User, authzService, repos.UserProfile)
localizationService := localization.NewService(repos.Localization) localizationService := localization.NewService(repos.Localization)
authService := app_auth.NewService(repos.User, jwtManager) 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) searchService := app_search.NewService(searchClient, localizationService)
s.App = app.NewApplication( s.App = app.NewApplication(