mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
Merge pull request #21 from SamyRai/feat/production-readiness-refactor
Refactor Services and Improve Documentation for Production Readiness
This commit is contained in:
commit
8a214b90fa
70
README.md
Normal file
70
README.md
Normal 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.
|
||||||
302
api/README.md
302
api/README.md
@ -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.
|
||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
internal/app/analytics/README.md
Normal file
57
internal/app/analytics/README.md
Normal 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.
|
||||||
52
internal/app/auth/README.md
Normal file
52
internal/app/auth/README.md
Normal 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.
|
||||||
10
internal/app/translation/dto.go
Normal file
10
internal/app/translation/dto.go
Normal 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
|
||||||
|
}
|
||||||
95
internal/app/translation/mock_repository_test.go
Normal file
95
internal/app/translation/mock_repository_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/app/translation/queries_test.go
Normal file
79
internal/app/translation/queries_test.go
Normal 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)
|
||||||
|
}
|
||||||
52
internal/app/work/README.md
Normal file
52
internal/app/work/README.md
Normal 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.
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
8
internal/app/work/dto.go
Normal 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
|
||||||
|
}
|
||||||
92
internal/app/work/mock_analytics_service_test.go
Normal file
92
internal/app/work/mock_analytics_service_test.go
Normal 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
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
50
internal/platform/db/README.md
Normal file
50
internal/platform/db/README.md
Normal 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.
|
||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user