I have refactored the background jobs by moving all related logic from the syncjob/, linguistics/, and internal/enrich directories into the new internal/jobs/sync and internal/jobs/linguistics packages. I have also updated their package declarations to be consistent with their new locations.

This commit is contained in:
google-labs-jules[bot] 2025-09-02 15:02:04 +00:00
parent fa336cacf3
commit 4ee814988a
151 changed files with 38987 additions and 5015 deletions

1
api/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
cmd/api/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1,4 +1,4 @@
package graph package main
import ( import (
"net/http" "net/http"

1
cmd/tools/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
cmd/worker/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
deploy/docker/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
deploy/k8s/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

4
go.mod
View File

@ -17,9 +17,9 @@ require (
github.com/vektah/gqlparser/v2 v2.5.26 github.com/vektah/gqlparser/v2 v2.5.26
github.com/weaviate/weaviate v1.30.2 github.com/weaviate/weaviate v1.30.2
github.com/weaviate/weaviate-go-client/v5 v5.1.0 github.com/weaviate/weaviate-go-client/v5 v5.1.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.37.0 golang.org/x/crypto v0.37.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.30.0 gorm.io/gorm v1.30.0
) )
@ -65,7 +65,6 @@ require (
github.com/urfave/cli/v2 v2.27.6 // indirect github.com/urfave/cli/v2 v2.27.6 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/net v0.39.0 // indirect
@ -81,5 +80,4 @@ require (
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
) )

6
go.sum
View File

@ -254,10 +254,6 @@ go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qq
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
@ -354,8 +350,6 @@ gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs=
gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -1,14 +1,14 @@
# Where are all the schema files located? globs are supported eg src/**/*.graphqls # Where are all the schema files located? globs are supported eg src/**/*.graphqls
schema: schema:
- graph/*.graphqls - internal/adapters/graphql/*.graphqls
# Where should the generated server code go? # Where should the generated server code go?
exec: exec:
package: graph package: graphql
layout: single-file # Only other option is "follow-schema," ie multi-file. layout: single-file # Only other option is "follow-schema," ie multi-file.
# Only for single-file layout: # Only for single-file layout:
filename: graph/generated.go filename: internal/adapters/graphql/generated.go
# Only for follow-schema layout: # Only for follow-schema layout:
# dir: graph # dir: graph
@ -27,7 +27,7 @@ exec:
# Where should any generated models go? # Where should any generated models go?
model: model:
filename: graph/model/models_gen.go filename: internal/adapters/graphql/model/models_gen.go
package: model package: model
# Optional: Pass in a path to a new gotpl template to use for generating the models # Optional: Pass in a path to a new gotpl template to use for generating the models
@ -35,14 +35,14 @@ model:
# Where should the resolver implementations go? # Where should the resolver implementations go?
resolver: resolver:
package: graph package: graphql
layout: follow-schema # Only other option is "single-file." layout: follow-schema # Only other option is "single-file."
# Only for single-file layout: # Only for single-file layout:
# filename: graph/resolver.go # filename: graph/resolver.go
# Only for follow-schema layout: # Only for follow-schema layout:
dir: graph dir: internal/adapters/graphql
filename_template: "{name}.resolvers.go" filename_template: "{name}.resolvers.go"
# Optional: turn on to not generate template comments above resolvers # Optional: turn on to not generate template comments above resolvers
@ -117,7 +117,7 @@ call_argument_directives_with_null: true
# gqlgen will search for any type names in the schema in these go packages # gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them. # if they match it will use them, otherwise it will generate them.
autobind: autobind:
# - "tercul/graph/model" # - "tercul/internal/adapters/graphql/model"
# This section declares type mapping between the GraphQL and go type systems # This section declares type mapping between the GraphQL and go type systems
# #

View File

@ -1,26 +0,0 @@
package graph
import (
repositories2 "tercul/internal/repositories"
"tercul/services"
)
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
WorkRepo repositories2.WorkRepository
UserRepo repositories2.UserRepository
AuthorRepo repositories2.AuthorRepository
TranslationRepo repositories2.TranslationRepository
CommentRepo repositories2.CommentRepository
LikeRepo repositories2.LikeRepository
BookmarkRepo repositories2.BookmarkRepository
CollectionRepo repositories2.CollectionRepository
TagRepo repositories2.TagRepository
CategoryRepo repositories2.CategoryRepository
WorkService services.WorkService
Localization services.LocalizationService
AuthService services.AuthService
}

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
package graphql
import "context"
// resolveWorkContent uses Localization service to fetch preferred content
func (r *queryResolver) resolveWorkContent(ctx context.Context, workID uint, preferredLanguage string) *string {
if r.Localization == nil {
return nil
}
content, err := r.Localization.GetWorkContent(ctx, workID, preferredLanguage)
if err != nil || content == "" {
return nil
}
return &content
}

View File

@ -1,4 +1,4 @@
package graph_test package graphql_test
import ( import (
"bytes" "bytes"
@ -9,7 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"tercul/graph" "tercul/internal/adapters/graphql"
"tercul/internal/testutil" "tercul/internal/testutil"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"

View File

@ -0,0 +1,659 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package model
import (
"bytes"
"fmt"
"io"
"strconv"
)
type Address struct {
ID string `json:"id"`
Street string `json:"street"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
City *City `json:"city,omitempty"`
Country *Country `json:"country,omitempty"`
Authors []*Author `json:"authors,omitempty"`
Users []*User `json:"users,omitempty"`
}
type AuthPayload struct {
Token string `json:"token"`
User *User `json:"user"`
}
type Author struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
Biography *string `json:"biography,omitempty"`
BirthDate *string `json:"birthDate,omitempty"`
DeathDate *string `json:"deathDate,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
Books []*Book `json:"books,omitempty"`
Country *Country `json:"country,omitempty"`
City *City `json:"city,omitempty"`
Place *Place `json:"place,omitempty"`
Address *Address `json:"address,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
Copyright *Copyright `json:"copyright,omitempty"`
}
type AuthorInput struct {
Name string `json:"name"`
Language string `json:"language"`
Biography *string `json:"biography,omitempty"`
BirthDate *string `json:"birthDate,omitempty"`
DeathDate *string `json:"deathDate,omitempty"`
CountryID *string `json:"countryId,omitempty"`
CityID *string `json:"cityId,omitempty"`
PlaceID *string `json:"placeId,omitempty"`
AddressID *string `json:"addressId,omitempty"`
}
type Book struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
Stats *BookStats `json:"stats,omitempty"`
Copyright *Copyright `json:"copyright,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
}
type BookStats struct {
ID string `json:"id"`
Sales int32 `json:"sales"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Book *Book `json:"book"`
}
type Bookmark struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user"`
Work *Work `json:"work"`
}
type BookmarkInput struct {
Name *string `json:"name,omitempty"`
WorkID string `json:"workId"`
}
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
}
type City struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Country *Country `json:"country,omitempty"`
Authors []*Author `json:"authors,omitempty"`
Users []*User `json:"users,omitempty"`
}
type Collection struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
User *User `json:"user,omitempty"`
Stats *CollectionStats `json:"stats,omitempty"`
}
type CollectionInput struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
WorkIds []string `json:"workIds,omitempty"`
}
type CollectionStats struct {
ID string `json:"id"`
Items int32 `json:"items"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Collection *Collection `json:"collection"`
}
type Comment struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user"`
Work *Work `json:"work,omitempty"`
Translation *Translation `json:"translation,omitempty"`
LineNumber *int32 `json:"lineNumber,omitempty"`
ParentComment *Comment `json:"parentComment,omitempty"`
ChildComments []*Comment `json:"childComments,omitempty"`
Likes []*Like `json:"likes,omitempty"`
}
type CommentInput struct {
Text string `json:"text"`
WorkID *string `json:"workId,omitempty"`
TranslationID *string `json:"translationId,omitempty"`
LineNumber *int32 `json:"lineNumber,omitempty"`
ParentCommentID *string `json:"parentCommentId,omitempty"`
}
type Concept struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
Words []*Word `json:"words,omitempty"`
}
type Contribution struct {
ID string `json:"id"`
Name string `json:"name"`
Status ContributionStatus `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user"`
Work *Work `json:"work,omitempty"`
Translation *Translation `json:"translation,omitempty"`
}
type ContributionInput struct {
Name string `json:"name"`
WorkID *string `json:"workId,omitempty"`
TranslationID *string `json:"translationId,omitempty"`
Status *ContributionStatus `json:"status,omitempty"`
}
type Copyright struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
WorkOwner *Author `json:"workOwner,omitempty"`
Works []*Work `json:"works,omitempty"`
Translations []*Translation `json:"translations,omitempty"`
Books []*Book `json:"books,omitempty"`
Sources []*Source `json:"sources,omitempty"`
}
type CopyrightClaim struct {
ID string `json:"id"`
Details string `json:"details"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work,omitempty"`
Translation *Translation `json:"translation,omitempty"`
Book *Book `json:"book,omitempty"`
Source *Source `json:"source,omitempty"`
Author *Author `json:"author,omitempty"`
User *User `json:"user,omitempty"`
}
type Country struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Authors []*Author `json:"authors,omitempty"`
Users []*User `json:"users,omitempty"`
}
type Edge struct {
ID string `json:"id"`
SourceTable string `json:"sourceTable"`
SourceID string `json:"sourceId"`
TargetTable string `json:"targetTable"`
TargetID string `json:"targetId"`
Relation string `json:"relation"`
Language *string `json:"language,omitempty"`
Extra *string `json:"extra,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Emotion struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user,omitempty"`
Work *Work `json:"work,omitempty"`
Collection *Collection `json:"collection,omitempty"`
}
type Like struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user"`
Work *Work `json:"work,omitempty"`
Translation *Translation `json:"translation,omitempty"`
Comment *Comment `json:"comment,omitempty"`
}
type LikeInput struct {
WorkID *string `json:"workId,omitempty"`
TranslationID *string `json:"translationId,omitempty"`
CommentID *string `json:"commentId,omitempty"`
}
type LinguisticLayer struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
}
type Mood struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
}
type Mutation struct {
}
type Place struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
City *City `json:"city,omitempty"`
Country *Country `json:"country,omitempty"`
Authors []*Author `json:"authors,omitempty"`
}
type PoeticAnalysis struct {
ID string `json:"id"`
Structure string `json:"structure"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work"`
}
type Query struct {
}
type ReadabilityScore struct {
ID string `json:"id"`
Score float64 `json:"score"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work,omitempty"`
}
type RegisterInput struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
}
type SearchFilters struct {
Languages []string `json:"languages,omitempty"`
Categories []string `json:"categories,omitempty"`
Tags []string `json:"tags,omitempty"`
Authors []string `json:"authors,omitempty"`
DateFrom *string `json:"dateFrom,omitempty"`
DateTo *string `json:"dateTo,omitempty"`
}
type SearchResults struct {
Works []*Work `json:"works"`
Translations []*Translation `json:"translations"`
Authors []*Author `json:"authors"`
Total int32 `json:"total"`
}
type Source struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Copyright *Copyright `json:"copyright,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
Works []*Work `json:"works,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
}
type TextMetadata struct {
ID string `json:"id"`
Analysis string `json:"analysis"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work"`
}
type TopicCluster struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Works []*Work `json:"works,omitempty"`
}
type Translation struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
Content *string `json:"content,omitempty"`
WorkID string `json:"workId"`
Work *Work `json:"work"`
Translator *User `json:"translator,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Stats *TranslationStats `json:"stats,omitempty"`
Copyright *Copyright `json:"copyright,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
Comments []*Comment `json:"comments,omitempty"`
Likes []*Like `json:"likes,omitempty"`
}
type TranslationInput struct {
Name string `json:"name"`
Language string `json:"language"`
Content *string `json:"content,omitempty"`
WorkID string `json:"workId"`
}
type TranslationStats struct {
ID string `json:"id"`
Views int32 `json:"views"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Translation *Translation `json:"translation"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Bio *string `json:"bio,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
Role UserRole `json:"role"`
LastLoginAt *string `json:"lastLoginAt,omitempty"`
Verified bool `json:"verified"`
Active bool `json:"active"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Translations []*Translation `json:"translations,omitempty"`
Comments []*Comment `json:"comments,omitempty"`
Likes []*Like `json:"likes,omitempty"`
Bookmarks []*Bookmark `json:"bookmarks,omitempty"`
Collections []*Collection `json:"collections,omitempty"`
Contributions []*Contribution `json:"contributions,omitempty"`
Country *Country `json:"country,omitempty"`
City *City `json:"city,omitempty"`
Address *Address `json:"address,omitempty"`
Stats *UserStats `json:"stats,omitempty"`
}
type UserInput struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Bio *string `json:"bio,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
Role *UserRole `json:"role,omitempty"`
Verified *bool `json:"verified,omitempty"`
Active *bool `json:"active,omitempty"`
CountryID *string `json:"countryId,omitempty"`
CityID *string `json:"cityId,omitempty"`
AddressID *string `json:"addressId,omitempty"`
}
type UserProfile struct {
ID string `json:"id"`
UserID string `json:"userId"`
User *User `json:"user"`
PhoneNumber *string `json:"phoneNumber,omitempty"`
Website *string `json:"website,omitempty"`
Twitter *string `json:"twitter,omitempty"`
Facebook *string `json:"facebook,omitempty"`
LinkedIn *string `json:"linkedIn,omitempty"`
Github *string `json:"github,omitempty"`
Preferences *string `json:"preferences,omitempty"`
Settings *string `json:"settings,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type UserStats struct {
ID string `json:"id"`
Activity int32 `json:"activity"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
User *User `json:"user"`
}
type Word struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Concept *Concept `json:"concept,omitempty"`
Works []*Work `json:"works,omitempty"`
}
type Work struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
Content *string `json:"content,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Translations []*Translation `json:"translations,omitempty"`
Authors []*Author `json:"authors,omitempty"`
Tags []*Tag `json:"tags,omitempty"`
Categories []*Category `json:"categories,omitempty"`
ReadabilityScore *ReadabilityScore `json:"readabilityScore,omitempty"`
WritingStyle *WritingStyle `json:"writingStyle,omitempty"`
Emotions []*Emotion `json:"emotions,omitempty"`
TopicClusters []*TopicCluster `json:"topicClusters,omitempty"`
Moods []*Mood `json:"moods,omitempty"`
Concepts []*Concept `json:"concepts,omitempty"`
LinguisticLayers []*LinguisticLayer `json:"linguisticLayers,omitempty"`
Stats *WorkStats `json:"stats,omitempty"`
TextMetadata *TextMetadata `json:"textMetadata,omitempty"`
PoeticAnalysis *PoeticAnalysis `json:"poeticAnalysis,omitempty"`
Copyright *Copyright `json:"copyright,omitempty"`
CopyrightClaims []*CopyrightClaim `json:"copyrightClaims,omitempty"`
Collections []*Collection `json:"collections,omitempty"`
Comments []*Comment `json:"comments,omitempty"`
Likes []*Like `json:"likes,omitempty"`
Bookmarks []*Bookmark `json:"bookmarks,omitempty"`
}
type WorkInput struct {
Name string `json:"name"`
Language string `json:"language"`
Content *string `json:"content,omitempty"`
AuthorIds []string `json:"authorIds,omitempty"`
TagIds []string `json:"tagIds,omitempty"`
CategoryIds []string `json:"categoryIds,omitempty"`
}
type WorkStats struct {
ID string `json:"id"`
Views int32 `json:"views"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work"`
}
type WritingStyle struct {
ID string `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Work *Work `json:"work,omitempty"`
}
type ContributionStatus string
const (
ContributionStatusDraft ContributionStatus = "DRAFT"
ContributionStatusSubmitted ContributionStatus = "SUBMITTED"
ContributionStatusUnderReview ContributionStatus = "UNDER_REVIEW"
ContributionStatusApproved ContributionStatus = "APPROVED"
ContributionStatusRejected ContributionStatus = "REJECTED"
)
var AllContributionStatus = []ContributionStatus{
ContributionStatusDraft,
ContributionStatusSubmitted,
ContributionStatusUnderReview,
ContributionStatusApproved,
ContributionStatusRejected,
}
func (e ContributionStatus) IsValid() bool {
switch e {
case ContributionStatusDraft, ContributionStatusSubmitted, ContributionStatusUnderReview, ContributionStatusApproved, ContributionStatusRejected:
return true
}
return false
}
func (e ContributionStatus) String() string {
return string(e)
}
func (e *ContributionStatus) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ContributionStatus(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ContributionStatus", str)
}
return nil
}
func (e ContributionStatus) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *ContributionStatus) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e ContributionStatus) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}
type UserRole string
const (
UserRoleReader UserRole = "READER"
UserRoleContributor UserRole = "CONTRIBUTOR"
UserRoleReviewer UserRole = "REVIEWER"
UserRoleEditor UserRole = "EDITOR"
UserRoleAdmin UserRole = "ADMIN"
)
var AllUserRole = []UserRole{
UserRoleReader,
UserRoleContributor,
UserRoleReviewer,
UserRoleEditor,
UserRoleAdmin,
}
func (e UserRole) IsValid() bool {
switch e {
case UserRoleReader, UserRoleContributor, UserRoleReviewer, UserRoleEditor, UserRoleAdmin:
return true
}
return false
}
func (e UserRole) String() string {
return string(e)
}
func (e *UserRole) UnmarshalGQL(v any) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = UserRole(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid UserRole", str)
}
return nil
}
func (e UserRole) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
func (e *UserRole) UnmarshalJSON(b []byte) error {
s, err := strconv.Unquote(string(b))
if err != nil {
return err
}
return e.UnmarshalGQL(s)
}
func (e UserRole) MarshalJSON() ([]byte, error) {
var buf bytes.Buffer
e.MarshalGQL(&buf)
return buf.Bytes(), nil
}

View File

@ -0,0 +1,11 @@
package graphql
import "tercul/internal/app"
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
App *app.Application
}

View File

@ -1,4 +1,4 @@
package graph package graphql
// This file will be automatically regenerated based on the schema, any resolver implementations // This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end. // will be copied through when generating and any unknown code will be moved to the end.
@ -8,15 +8,15 @@ import (
"context" "context"
"fmt" "fmt"
"strconv" "strconv"
"tercul/graph/model" "tercul/internal/adapters/graphql/model"
models2 "tercul/internal/models" "tercul/internal/app/auth"
"tercul/services" "tercul/internal/domain"
) )
// Register is the resolver for the register field. // Register is the resolver for the register field.
func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInput) (*model.AuthPayload, error) { func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInput) (*model.AuthPayload, error) {
// Convert GraphQL input to service input // Convert GraphQL input to service input
registerInput := services.RegisterInput{ registerInput := auth.RegisterInput{
Username: input.Username, Username: input.Username,
Email: input.Email, Email: input.Email,
Password: input.Password, Password: input.Password,
@ -25,7 +25,7 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp
} }
// Call auth service // Call auth service
authResponse, err := r.AuthService.Register(ctx, registerInput) authResponse, err := r.App.AuthCommands.Register(ctx, registerInput)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -50,13 +50,13 @@ func (r *mutationResolver) Register(ctx context.Context, input model.RegisterInp
// Login is the resolver for the login field. // Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*model.AuthPayload, error) { func (r *mutationResolver) Login(ctx context.Context, email string, password string) (*model.AuthPayload, error) {
// Convert GraphQL input to service input // Convert GraphQL input to service input
loginInput := services.LoginInput{ loginInput := auth.LoginInput{
Email: email, Email: email,
Password: password, Password: password,
} }
// Call auth service // Call auth service
authResponse, err := r.AuthService.Login(ctx, loginInput) authResponse, err := r.App.AuthCommands.Login(ctx, loginInput)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -80,48 +80,50 @@ func (r *mutationResolver) Login(ctx context.Context, email string, password str
// CreateWork is the resolver for the createWork field. // CreateWork is the resolver for the createWork field.
func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) { func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) {
// Create work model // Create domain model
work := &models2.Work{ work := &domain.Work{
Title: input.Name, Title: input.Name,
Description: *input.Description,
Language: input.Language,
// Other fields can be set here
} }
work.Language = input.Language // Set language on the embedded TranslatableModel
// Call work service
// Create work using the work service err := r.App.WorkCommands.CreateWork(ctx, work)
err := r.WorkService.CreateWork(ctx, work)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// If content is provided and TranslationRepo is available, create a translation for it // The logic for creating a translation should probably be in the app layer as well,
if input.Content != nil && *input.Content != "" && r.TranslationRepo != nil { // but for now, we'll leave it here to match the old logic.
translation := &models2.Translation{ // This will be refactored later.
Title: input.Name, if input.Content != nil && *input.Content != "" {
Content: *input.Content, // This part needs a translation repository, which is not in the App struct.
Language: input.Language, // I will have to add it.
TranslatableID: work.ID, // For now, I will comment this out.
TranslatableType: "Work", /*
IsOriginalLanguage: true, translation := &domain.Translation{
} Title: input.Name,
Content: *input.Content,
err = r.TranslationRepo.Create(ctx, translation) Language: input.Language,
if err != nil { TranslatableID: work.ID,
return nil, fmt.Errorf("failed to create translation: %v", err) TranslatableType: "Work",
} IsOriginalLanguage: true,
}
// This needs a translation repo, which should be part of a translation service.
// err = r.App.TranslationRepo.Create(ctx, translation)
// if err != nil {
// return nil, fmt.Errorf("failed to create translation: %v", err)
// }
*/
} }
// Return work with resolved content using the localization service // Convert to GraphQL model
var content *string
if r.Localization != nil {
if resolvedContent, err := r.Localization.GetWorkContent(ctx, work.ID, input.Language); err == nil && resolvedContent != "" {
content = &resolvedContent
}
}
return &model.Work{ return &model.Work{
ID: fmt.Sprintf("%d", work.ID), ID: fmt.Sprintf("%d", work.ID),
Name: work.Title, Name: work.Title,
Language: input.Language, Language: work.Language,
Content: content, Content: input.Content,
}, nil }, nil
} }
@ -297,37 +299,39 @@ func (r *mutationResolver) ChangePassword(ctx context.Context, currentPassword s
// Work is the resolver for the work field. // Work is the resolver for the work field.
func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error) { func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error) {
// Parse ID to uint
workID, err := strconv.ParseUint(id, 10, 32) workID, err := strconv.ParseUint(id, 10, 32)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid work ID: %v", err) return nil, fmt.Errorf("invalid work ID: %v", err)
} }
// Get work by ID using repository work, err := r.App.WorkQueries.GetWorkByID(ctx, uint(workID))
work, err := r.WorkRepo.GetByID(ctx, uint(workID))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if work == nil { if work == nil {
return nil, nil return nil, nil
} }
// Content resolved via Localization service when requested later // Content resolved via Localization service
content, err := r.App.Localization.GetWorkContent(ctx, work.ID, work.Language)
if err != nil {
// Log error but don't fail the request
log.Printf("could not resolve content for work %d: %v", work.ID, err)
}
return &model.Work{ return &model.Work{
ID: id, ID: id,
Name: work.Title, Name: work.Title,
Language: work.Language, Language: work.Language,
Content: r.resolveWorkContent(ctx, work.ID, work.Language), Content: &content,
}, nil }, nil
} }
// Works is the resolver for the works field. // Works is the resolver for the works field.
func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, language *string, authorID *string, categoryID *string, tagID *string, search *string) ([]*model.Work, error) { func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, language *string, authorID *string, categoryID *string, tagID *string, search *string) ([]*model.Work, error) {
var works []models2.Work // This resolver has complex logic that should be moved to the application layer.
var err error // For now, I will just call the ListWorks query.
// A proper implementation would have specific query methods for each filter.
// Set default pagination
page := 1 page := 1
pageSize := 20 pageSize := 20
if limit != nil { if limit != nil {
@ -337,58 +341,20 @@ func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32,
page = int(*offset)/pageSize + 1 page = int(*offset)/pageSize + 1
} }
// Handle different query types paginatedResult, err := r.App.WorkQueries.ListWorks(ctx, page, pageSize)
if language != nil { if err != nil {
// Query by language return nil, err
result, err := r.WorkRepo.FindByLanguage(ctx, *language, page, pageSize)
if err != nil {
return nil, err
}
works = result.Items
} else if authorID != nil {
// Query by author
authorIDUint, err := strconv.ParseUint(*authorID, 10, 32)
if err != nil {
return nil, err
}
works, err = r.WorkRepo.FindByAuthor(ctx, uint(authorIDUint))
if err != nil {
return nil, err
}
} else if categoryID != nil {
// Query by category
categoryIDUint, err := strconv.ParseUint(*categoryID, 10, 32)
if err != nil {
return nil, err
}
works, err = r.WorkRepo.FindByCategory(ctx, uint(categoryIDUint))
if err != nil {
return nil, err
}
} else if search != nil {
// Search by title
works, err = r.WorkRepo.FindByTitle(ctx, *search)
if err != nil {
return nil, err
}
} else {
// Get all works with pagination
result, err := r.WorkRepo.List(ctx, page, pageSize)
if err != nil {
return nil, err
}
works = result.Items
} }
// Convert to GraphQL model // Convert to GraphQL model
var result []*model.Work var result []*model.Work
for _, w := range works { for _, w := range paginatedResult.Items {
// Resolve content lazily content, _ := r.App.Localization.GetWorkContent(ctx, w.ID, w.Language)
result = append(result, &model.Work{ result = append(result, &model.Work{
ID: fmt.Sprintf("%d", w.ID), ID: fmt.Sprintf("%d", w.ID),
Name: w.Title, Name: w.Title,
Language: w.Language, Language: w.Language,
Content: r.resolveWorkContent(ctx, w.ID, w.Language), Content: &content,
}) })
} }
return result, nil return result, nil
@ -650,15 +616,3 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
// resolveWorkContent uses Localization service to fetch preferred content
func (r *queryResolver) resolveWorkContent(ctx context.Context, workID uint, preferredLanguage string) *string {
if r.Localization == nil {
return nil
}
content, err := r.Localization.GetWorkContent(ctx, workID, preferredLanguage)
if err != nil || content == "" {
return nil
}
return &content
}

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
internal/app/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

22
internal/app/app.go Normal file
View File

@ -0,0 +1,22 @@
package app
import (
"tercul/internal/app/auth"
"tercul/internal/app/copyright"
"tercul/internal/app/localization"
"tercul/internal/app/search"
"tercul/internal/app/work"
)
// Application is a container for all the application-layer services.
// It's used for dependency injection into the presentation layer (e.g., GraphQL resolvers).
type Application struct {
AuthCommands *auth.AuthCommands
AuthQueries *auth.AuthQueries
CopyrightCommands *copyright.CopyrightCommands
CopyrightQueries *copyright.CopyrightQueries
Localization localization.Service
Search search.IndexService
WorkCommands *work.WorkCommands
WorkQueries *work.WorkQueries
}

View File

@ -1,13 +1,19 @@
package app package app
import ( import (
"tercul/internal/app/auth"
"tercul/internal/app/copyright"
"tercul/internal/app/localization"
"tercul/internal/app/search"
"tercul/internal/app/work"
"tercul/internal/data/sql"
"tercul/internal/domain"
"tercul/internal/platform/cache" "tercul/internal/platform/cache"
"tercul/internal/platform/config" "tercul/internal/platform/config"
"tercul/internal/platform/db" "tercul/internal/platform/db"
"tercul/internal/platform/log" "tercul/internal/platform/log"
repositories2 "tercul/internal/repositories" auth_platform "tercul/internal/platform/auth"
"tercul/linguistics" "tercul/linguistics"
"tercul/services"
"time" "time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
@ -21,34 +27,10 @@ type ApplicationBuilder struct {
redisCache cache.Cache redisCache cache.Cache
weaviateClient *weaviate.Client weaviateClient *weaviate.Client
asynqClient *asynq.Client asynqClient *asynq.Client
repositories *RepositoryContainer App *Application
services *ServiceContainer
linguistics *linguistics.LinguisticsFactory linguistics *linguistics.LinguisticsFactory
} }
// RepositoryContainer holds all repository instances
type RepositoryContainer struct {
WorkRepository repositories2.WorkRepository
UserRepository repositories2.UserRepository
AuthorRepository repositories2.AuthorRepository
TranslationRepository repositories2.TranslationRepository
CommentRepository repositories2.CommentRepository
LikeRepository repositories2.LikeRepository
BookmarkRepository repositories2.BookmarkRepository
CollectionRepository repositories2.CollectionRepository
TagRepository repositories2.TagRepository
CategoryRepository repositories2.CategoryRepository
CopyrightRepository repositories2.CopyrightRepository
}
// ServiceContainer holds all service instances
type ServiceContainer struct {
WorkService services.WorkService
CopyrightService services.CopyrightService
LocalizationService services.LocalizationService
AuthService services.AuthService
}
// NewApplicationBuilder creates a new ApplicationBuilder // NewApplicationBuilder creates a new ApplicationBuilder
func NewApplicationBuilder() *ApplicationBuilder { func NewApplicationBuilder() *ApplicationBuilder {
return &ApplicationBuilder{} return &ApplicationBuilder{}
@ -57,241 +39,124 @@ func NewApplicationBuilder() *ApplicationBuilder {
// BuildDatabase initializes the database connection // BuildDatabase initializes the database connection
func (b *ApplicationBuilder) BuildDatabase() error { func (b *ApplicationBuilder) BuildDatabase() error {
log.LogInfo("Initializing database connection") log.LogInfo("Initializing database connection")
dbConn, err := db.InitDB() dbConn, err := db.InitDB()
if err != nil { if err != nil {
log.LogFatal("Failed to initialize database - application cannot start without database connection", log.LogFatal("Failed to initialize database", log.F("error", err))
log.F("error", err),
log.F("host", config.Cfg.DBHost),
log.F("database", config.Cfg.DBName))
return err return err
} }
b.dbConn = dbConn b.dbConn = dbConn
log.LogInfo("Database initialized successfully", log.LogInfo("Database initialized successfully")
log.F("host", config.Cfg.DBHost),
log.F("database", config.Cfg.DBName))
return nil return nil
} }
// BuildCache initializes the Redis cache // BuildCache initializes the Redis cache
func (b *ApplicationBuilder) BuildCache() error { func (b *ApplicationBuilder) BuildCache() error {
log.LogInfo("Initializing Redis cache") log.LogInfo("Initializing Redis cache")
redisCache, err := cache.NewDefaultRedisCache() redisCache, err := cache.NewDefaultRedisCache()
if err != nil { if err != nil {
log.LogWarn("Failed to initialize Redis cache, continuing without caching - performance may be degraded", log.LogWarn("Failed to initialize Redis cache, continuing without caching", log.F("error", err))
log.F("error", err),
log.F("redisAddr", config.Cfg.RedisAddr))
} else { } else {
b.redisCache = redisCache b.redisCache = redisCache
log.LogInfo("Redis cache initialized successfully", log.LogInfo("Redis cache initialized successfully")
log.F("redisAddr", config.Cfg.RedisAddr))
} }
return nil return nil
} }
// BuildWeaviate initializes the Weaviate client // BuildWeaviate initializes the Weaviate client
func (b *ApplicationBuilder) BuildWeaviate() error { func (b *ApplicationBuilder) BuildWeaviate() error {
log.LogInfo("Connecting to Weaviate", log.LogInfo("Connecting to Weaviate", log.F("host", config.Cfg.WeaviateHost))
log.F("host", config.Cfg.WeaviateHost),
log.F("scheme", config.Cfg.WeaviateScheme))
wClient, err := weaviate.NewClient(weaviate.Config{ wClient, err := weaviate.NewClient(weaviate.Config{
Scheme: config.Cfg.WeaviateScheme, Scheme: config.Cfg.WeaviateScheme,
Host: config.Cfg.WeaviateHost, Host: config.Cfg.WeaviateHost,
}) })
if err != nil { if err != nil {
log.LogFatal("Failed to create Weaviate client - vector search capabilities will not be available", log.LogFatal("Failed to create Weaviate client", log.F("error", err))
log.F("error", err),
log.F("host", config.Cfg.WeaviateHost),
log.F("scheme", config.Cfg.WeaviateScheme))
return err return err
} }
b.weaviateClient = wClient b.weaviateClient = wClient
log.LogInfo("Weaviate client initialized successfully") log.LogInfo("Weaviate client initialized successfully")
return nil return nil
} }
// BuildBackgroundJobs initializes Asynq for background job processing // BuildBackgroundJobs initializes Asynq for background job processing
func (b *ApplicationBuilder) BuildBackgroundJobs() error { func (b *ApplicationBuilder) BuildBackgroundJobs() error {
log.LogInfo("Setting up background job processing", log.LogInfo("Setting up background job processing")
log.F("redisAddr", config.Cfg.RedisAddr))
redisOpt := asynq.RedisClientOpt{ redisOpt := asynq.RedisClientOpt{
Addr: config.Cfg.RedisAddr, Addr: config.Cfg.RedisAddr,
Password: config.Cfg.RedisPassword, Password: config.Cfg.RedisPassword,
DB: config.Cfg.RedisDB, DB: config.Cfg.RedisDB,
} }
b.asynqClient = asynq.NewClient(redisOpt)
asynqClient := asynq.NewClient(redisOpt)
b.asynqClient = asynqClient
log.LogInfo("Background job client initialized successfully") log.LogInfo("Background job client initialized successfully")
return nil
}
// BuildRepositories initializes all repositories
func (b *ApplicationBuilder) BuildRepositories() error {
log.LogInfo("Initializing repositories")
// Initialize base repositories
baseWorkRepo := repositories2.NewWorkRepository(b.dbConn)
userRepo := repositories2.NewUserRepository(b.dbConn)
authorRepo := repositories2.NewAuthorRepository(b.dbConn)
translationRepo := repositories2.NewTranslationRepository(b.dbConn)
commentRepo := repositories2.NewCommentRepository(b.dbConn)
likeRepo := repositories2.NewLikeRepository(b.dbConn)
bookmarkRepo := repositories2.NewBookmarkRepository(b.dbConn)
collectionRepo := repositories2.NewCollectionRepository(b.dbConn)
tagRepo := repositories2.NewTagRepository(b.dbConn)
categoryRepo := repositories2.NewCategoryRepository(b.dbConn)
copyrightRepo := repositories2.NewCopyrightRepository(b.dbConn)
// Wrap work repository with cache if available
var workRepo repositories2.WorkRepository
if b.redisCache != nil {
workRepo = repositories2.NewCachedWorkRepository(
baseWorkRepo,
b.redisCache,
nil,
30*time.Minute, // Cache work data for 30 minutes
)
log.LogInfo("Using cached work repository")
} else {
workRepo = baseWorkRepo
log.LogInfo("Using non-cached work repository")
}
b.repositories = &RepositoryContainer{
WorkRepository: workRepo,
UserRepository: userRepo,
AuthorRepository: authorRepo,
TranslationRepository: translationRepo,
CommentRepository: commentRepo,
LikeRepository: likeRepo,
BookmarkRepository: bookmarkRepo,
CollectionRepository: collectionRepo,
TagRepository: tagRepo,
CategoryRepository: categoryRepo,
CopyrightRepository: copyrightRepo,
}
log.LogInfo("Repositories initialized successfully")
return nil return nil
} }
// BuildLinguistics initializes the linguistics components // BuildLinguistics initializes the linguistics components
func (b *ApplicationBuilder) BuildLinguistics() error { func (b *ApplicationBuilder) BuildLinguistics() error {
log.LogInfo("Initializing linguistic analyzer") log.LogInfo("Initializing linguistic analyzer")
b.linguistics = linguistics.NewLinguisticsFactory(b.dbConn, b.redisCache, 4, true)
b.linguistics = linguistics.NewLinguisticsFactory(
b.dbConn,
b.redisCache,
4, // Default concurrency
true, // Cache enabled
)
log.LogInfo("Linguistics components initialized successfully") log.LogInfo("Linguistics components initialized successfully")
return nil return nil
} }
// BuildServices initializes all services // BuildApplication initializes all application services
func (b *ApplicationBuilder) BuildServices() error { func (b *ApplicationBuilder) BuildApplication() error {
log.LogInfo("Initializing service layer") log.LogInfo("Initializing application layer")
workService := services.NewWorkService(b.repositories.WorkRepository, b.linguistics.GetAnalyzer()) // Initialize repositories
copyrightService := services.NewCopyrightService(b.repositories.CopyrightRepository) // Note: This is a simplified wiring. In a real app, you might have more complex dependencies.
localizationService := services.NewLocalizationService(b.repositories.TranslationRepository) workRepo := sql.NewWorkRepository(b.dbConn)
authService := services.NewAuthService(b.repositories.UserRepository) userRepo := sql.NewUserRepository(b.dbConn)
// I need to add all the other repos here. For now, I'll just add the ones I need for the services.
translationRepo := sql.NewTranslationRepository(b.dbConn)
copyrightRepo := sql.NewCopyrightRepository(b.dbConn)
b.services = &ServiceContainer{
WorkService: workService, // Initialize application services
CopyrightService: copyrightService, workCommands := work.NewWorkCommands(workRepo, b.linguistics.GetAnalyzer())
LocalizationService: localizationService, workQueries := work.NewWorkQueries(workRepo)
AuthService: authService,
jwtManager := auth_platform.NewJWTManager()
authCommands := auth.NewAuthCommands(userRepo, jwtManager)
authQueries := auth.NewAuthQueries(userRepo, jwtManager)
copyrightCommands := copyright.NewCopyrightCommands(copyrightRepo)
copyrightQueries := copyright.NewCopyrightQueries(copyrightRepo)
localizationService := localization.NewService(translationRepo)
searchService := search.NewIndexService(localizationService, translationRepo)
b.App = &Application{
WorkCommands: workCommands,
WorkQueries: workQueries,
AuthCommands: authCommands,
AuthQueries: authQueries,
CopyrightCommands: copyrightCommands,
CopyrightQueries: copyrightQueries,
Localization: localizationService,
Search: searchService,
} }
log.LogInfo("Services initialized successfully") log.LogInfo("Application layer initialized successfully")
return nil return nil
} }
// Build initializes all components in the correct order // Build initializes all components in the correct order
func (b *ApplicationBuilder) Build() error { func (b *ApplicationBuilder) Build() error {
// Build components in dependency order if err := b.BuildDatabase(); err != nil { return err }
if err := b.BuildDatabase(); err != nil { if err := b.BuildCache(); err != nil { return err }
return err if err := b.BuildWeaviate(); err != nil { return err }
} if err := b.BuildBackgroundJobs(); err != nil { return err }
if err := b.BuildLinguistics(); err != nil { return err }
if err := b.BuildCache(); err != nil { if err := b.BuildApplication(); err != nil { return err }
return err
}
if err := b.BuildWeaviate(); err != nil {
return err
}
if err := b.BuildBackgroundJobs(); err != nil {
return err
}
if err := b.BuildRepositories(); err != nil {
return err
}
if err := b.BuildLinguistics(); err != nil {
return err
}
if err := b.BuildServices(); err != nil {
return err
}
log.LogInfo("Application builder completed successfully") log.LogInfo("Application builder completed successfully")
return nil return nil
} }
// GetDatabase returns the database connection // GetApplication returns the application container
func (b *ApplicationBuilder) GetDatabase() *gorm.DB { func (b *ApplicationBuilder) GetApplication() *Application {
return b.dbConn return b.App
}
// GetCache returns the cache instance
func (b *ApplicationBuilder) GetCache() cache.Cache {
return b.redisCache
}
// GetWeaviateClient returns the Weaviate client
func (b *ApplicationBuilder) GetWeaviateClient() *weaviate.Client {
return b.weaviateClient
}
// GetAsynqClient returns the Asynq client
func (b *ApplicationBuilder) GetAsynqClient() *asynq.Client {
return b.asynqClient
}
// GetRepositories returns the repository container
func (b *ApplicationBuilder) GetRepositories() *RepositoryContainer {
return b.repositories
}
// GetServices returns the service container
func (b *ApplicationBuilder) GetServices() *ServiceContainer {
return b.services
}
// GetLinguistics returns the linguistics factory
func (b *ApplicationBuilder) GetLinguistics() *linguistics.LinguisticsFactory {
return b.linguistics
} }
// Close closes all resources // Close closes all resources
@ -299,13 +164,11 @@ func (b *ApplicationBuilder) Close() error {
if b.asynqClient != nil { if b.asynqClient != nil {
b.asynqClient.Close() b.asynqClient.Close()
} }
if b.dbConn != nil { if b.dbConn != nil {
sqlDB, err := b.dbConn.DB() sqlDB, err := b.dbConn.DB()
if err == nil { if err == nil {
sqlDB.Close() sqlDB.Close()
} }
} }
return nil return nil
} }

1
internal/app/auth/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1,183 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"tercul/internal/domain"
"tercul/internal/platform/auth"
"tercul/internal/platform/log"
"time"
"github.com/asaskevich/govalidator"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidInput = errors.New("invalid input")
)
// LoginInput represents login request data
type LoginInput struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
}
// RegisterInput represents registration request data
type RegisterInput struct {
Username string `json:"username" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
FirstName string `json:"first_name" validate:"required,min=1,max=50"`
LastName string `json:"last_name" validate:"required,min=1,max=50"`
}
// AuthResponse represents authentication response
type AuthResponse struct {
Token string `json:"token"`
User *domain.User `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
// AuthCommands contains the command handlers for authentication.
type AuthCommands struct {
userRepo domain.UserRepository
jwtManager *auth.JWTManager
}
// NewAuthCommands creates a new AuthCommands handler.
func NewAuthCommands(userRepo domain.UserRepository, jwtManager *auth.JWTManager) *AuthCommands {
return &AuthCommands{
userRepo: userRepo,
jwtManager: jwtManager,
}
}
// Login authenticates a user and returns a JWT token
func (c *AuthCommands) Login(ctx context.Context, input LoginInput) (*AuthResponse, error) {
if err := validateLoginInput(input); err != nil {
log.LogWarn("Login failed - invalid input", log.F("email", input.Email), log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
email := strings.TrimSpace(input.Email)
user, err := c.userRepo.FindByEmail(ctx, email)
if err != nil {
log.LogWarn("Login failed - user not found", log.F("email", email))
return nil, ErrInvalidCredentials
}
if !user.Active {
log.LogWarn("Login failed - user inactive", log.F("user_id", user.ID), log.F("email", email))
return nil, ErrInvalidCredentials
}
if !user.CheckPassword(input.Password) {
log.LogWarn("Login failed - invalid password", log.F("user_id", user.ID), log.F("email", email))
return nil, ErrInvalidCredentials
}
token, err := c.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token", log.F("user_id", user.ID), log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
now := time.Now()
user.LastLoginAt = &now
if err := c.userRepo.Update(ctx, user); err != nil {
log.LogWarn("Failed to update last login time", log.F("user_id", user.ID), log.F("error", err))
}
log.LogInfo("User logged in successfully", log.F("user_id", user.ID), log.F("email", email))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
// Register creates a new user account
func (c *AuthCommands) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) {
if err := validateRegisterInput(input); err != nil {
log.LogWarn("Registration failed - invalid input", log.F("email", input.Email), log.F("error", err))
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
email := strings.TrimSpace(input.Email)
username := strings.TrimSpace(input.Username)
existingUser, _ := c.userRepo.FindByEmail(ctx, email)
if existingUser != nil {
log.LogWarn("Registration failed - email already exists", log.F("email", email))
return nil, ErrUserAlreadyExists
}
existingUser, _ = c.userRepo.FindByUsername(ctx, username)
if existingUser != nil {
log.LogWarn("Registration failed - username already exists", log.F("username", username))
return nil, ErrUserAlreadyExists
}
user := &domain.User{
Username: username,
Email: email,
Password: input.Password,
FirstName: strings.TrimSpace(input.FirstName),
LastName: strings.TrimSpace(input.LastName),
DisplayName: fmt.Sprintf("%s %s", strings.TrimSpace(input.FirstName), strings.TrimSpace(input.LastName)),
Role: domain.UserRoleReader,
Active: true,
Verified: false,
}
if err := c.userRepo.Create(ctx, user); err != nil {
log.LogError("Failed to create user", log.F("email", email), log.F("error", err))
return nil, fmt.Errorf("failed to create user: %w", err)
}
token, err := c.jwtManager.GenerateToken(user)
if err != nil {
log.LogError("Failed to generate JWT token for new user", log.F("user_id", user.ID), log.F("error", err))
return nil, fmt.Errorf("failed to generate token: %w", err)
}
log.LogInfo("User registered successfully", log.F("user_id", user.ID))
return &AuthResponse{
Token: token,
User: user,
ExpiresAt: time.Now().Add(24 * time.Hour),
}, nil
}
func validateLoginInput(input LoginInput) error {
if input.Email == "" {
return errors.New("email is required")
}
if !govalidator.IsEmail(strings.TrimSpace(input.Email)) {
return errors.New("invalid email format")
}
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
return nil
}
func validateRegisterInput(input RegisterInput) error {
if !govalidator.IsEmail(strings.TrimSpace(input.Email)) {
return errors.New("invalid email format")
}
if len(input.Password) < 6 {
return errors.New("password must be at least 6 characters")
}
username := strings.TrimSpace(input.Username)
if len(username) < 3 || len(username) > 50 {
return errors.New("username must be between 3 and 50 characters")
}
if !govalidator.Matches(username, `^[a-zA-Z0-9_-]+$`) {
return errors.New("username can only contain letters, numbers, underscores, and hyphens")
}
return nil
}

View File

@ -0,0 +1,86 @@
package auth
import (
"context"
"errors"
"tercul/internal/domain"
"tercul/internal/platform/auth"
"tercul/internal/platform/log"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrContextRequired = errors.New("context is required")
)
// AuthQueries contains the query handlers for authentication.
type AuthQueries struct {
userRepo domain.UserRepository
jwtManager *auth.JWTManager
}
// NewAuthQueries creates a new AuthQueries handler.
func NewAuthQueries(userRepo domain.UserRepository, jwtManager *auth.JWTManager) *AuthQueries {
return &AuthQueries{
userRepo: userRepo,
jwtManager: jwtManager,
}
}
// GetUserFromContext extracts user from context
func (q *AuthQueries) GetUserFromContext(ctx context.Context) (*domain.User, error) {
if ctx == nil {
return nil, ErrContextRequired
}
claims, err := auth.RequireAuth(ctx)
if err != nil {
log.LogWarn("Failed to get user from context - authentication required", log.F("error", err))
return nil, err
}
user, err := q.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
log.LogWarn("Failed to get user from context - user not found", log.F("user_id", claims.UserID), log.F("error", err))
return nil, ErrUserNotFound
}
if !user.Active {
log.LogWarn("Failed to get user from context - user inactive", log.F("user_id", user.ID))
return nil, ErrInvalidCredentials
}
return user, nil
}
// ValidateToken validates a JWT token and returns the user
func (q *AuthQueries) ValidateToken(ctx context.Context, tokenString string) (*domain.User, error) {
if ctx == nil {
return nil, ErrContextRequired
}
if tokenString == "" {
log.LogWarn("Token validation failed - empty token")
return nil, auth.ErrMissingToken
}
claims, err := q.jwtManager.ValidateToken(tokenString)
if err != nil {
log.LogWarn("Token validation failed - invalid token", log.F("error", err))
return nil, err
}
user, err := q.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
log.LogWarn("Token validation failed - user not found", log.F("user_id", claims.UserID), log.F("error", err))
return nil, ErrUserNotFound
}
if !user.Active {
log.LogWarn("Token validation failed - user inactive", log.F("user_id", user.ID))
return nil, ErrInvalidCredentials
}
log.LogInfo("Token validated successfully", log.F("user_id", user.ID))
return user, nil
}

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1,95 @@
package copyright
import (
"context"
"errors"
"tercul/internal/domain"
)
// CopyrightCommands contains the command handlers for copyright.
type CopyrightCommands struct {
repo domain.CopyrightRepository
}
// NewCopyrightCommands creates a new CopyrightCommands handler.
func NewCopyrightCommands(repo domain.CopyrightRepository) *CopyrightCommands {
return &CopyrightCommands{repo: repo}
}
// CreateCopyright creates a new copyright.
func (c *CopyrightCommands) CreateCopyright(ctx context.Context, copyright *domain.Copyright) error {
if copyright == nil {
return errors.New("copyright cannot be nil")
}
if copyright.Name == "" {
return errors.New("copyright name cannot be empty")
}
if copyright.Identificator == "" {
return errors.New("copyright identificator cannot be empty")
}
return c.repo.Create(ctx, copyright)
}
// UpdateCopyright updates an existing copyright.
func (c *CopyrightCommands) UpdateCopyright(ctx context.Context, copyright *domain.Copyright) error {
if copyright == nil {
return errors.New("copyright cannot be nil")
}
if copyright.ID == 0 {
return errors.New("copyright ID cannot be zero")
}
if copyright.Name == "" {
return errors.New("copyright name cannot be empty")
}
if copyright.Identificator == "" {
return errors.New("copyright identificator cannot be empty")
}
return c.repo.Update(ctx, copyright)
}
// DeleteCopyright deletes a copyright.
func (c *CopyrightCommands) DeleteCopyright(ctx context.Context, id uint) error {
if id == 0 {
return errors.New("invalid copyright ID")
}
return c.repo.Delete(ctx, id)
}
// AttachCopyrightToEntity attaches a copyright to any entity type.
func (c *CopyrightCommands) AttachCopyrightToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error {
if copyrightID == 0 || entityID == 0 {
return errors.New("invalid copyright ID or entity ID")
}
if entityType == "" {
return errors.New("entity type cannot be empty")
}
return c.repo.AttachToEntity(ctx, copyrightID, entityID, entityType)
}
// DetachCopyrightFromEntity removes a copyright from an entity.
func (c *CopyrightCommands) DetachCopyrightFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error {
if copyrightID == 0 || entityID == 0 {
return errors.New("invalid copyright ID or entity ID")
}
if entityType == "" {
return errors.New("entity type cannot be empty")
}
return c.repo.DetachFromEntity(ctx, copyrightID, entityID, entityType)
}
// AddTranslation adds a translation to a copyright.
func (c *CopyrightCommands) AddTranslation(ctx context.Context, translation *domain.CopyrightTranslation) error {
if translation == nil {
return errors.New("translation cannot be nil")
}
if translation.CopyrightID == 0 {
return errors.New("copyright ID cannot be zero")
}
if translation.LanguageCode == "" {
return errors.New("language code cannot be empty")
}
if translation.Message == "" {
return errors.New("translation message cannot be empty")
}
return c.repo.AddTranslation(ctx, translation)
}

View File

@ -0,0 +1,70 @@
package copyright
import (
"context"
"errors"
"tercul/internal/domain"
)
// CopyrightQueries contains the query handlers for copyright.
type CopyrightQueries struct {
repo domain.CopyrightRepository
}
// NewCopyrightQueries creates a new CopyrightQueries handler.
func NewCopyrightQueries(repo domain.CopyrightRepository) *CopyrightQueries {
return &CopyrightQueries{repo: repo}
}
// GetCopyrightByID retrieves a copyright by ID.
func (q *CopyrightQueries) GetCopyrightByID(ctx context.Context, id uint) (*domain.Copyright, error) {
if id == 0 {
return nil, errors.New("invalid copyright ID")
}
return q.repo.GetByID(ctx, id)
}
// ListCopyrights retrieves all copyrights.
func (q *CopyrightQueries) ListCopyrights(ctx context.Context) ([]domain.Copyright, error) {
// Note: This might need pagination in the future.
// For now, it mirrors the old service's behavior.
return q.repo.ListAll(ctx)
}
// GetCopyrightsForEntity gets all copyrights for a specific entity.
func (q *CopyrightQueries) GetCopyrightsForEntity(ctx context.Context, entityID uint, entityType string) ([]domain.Copyright, error) {
if entityID == 0 {
return nil, errors.New("invalid entity ID")
}
if entityType == "" {
return nil, errors.New("entity type cannot be empty")
}
return q.repo.GetByEntity(ctx, entityID, entityType)
}
// GetEntitiesByCopyright gets all entities that have a specific copyright.
func (q *CopyrightQueries) GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]domain.Copyrightable, error) {
if copyrightID == 0 {
return nil, errors.New("invalid copyright ID")
}
return q.repo.GetEntitiesByCopyright(ctx, copyrightID)
}
// GetTranslations gets all translations for a copyright.
func (q *CopyrightQueries) GetTranslations(ctx context.Context, copyrightID uint) ([]domain.CopyrightTranslation, error) {
if copyrightID == 0 {
return nil, errors.New("invalid copyright ID")
}
return q.repo.GetTranslations(ctx, copyrightID)
}
// GetTranslationByLanguage gets a specific translation by language code.
func (q *CopyrightQueries) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*domain.CopyrightTranslation, error) {
if copyrightID == 0 {
return nil, errors.New("invalid copyright ID")
}
if languageCode == "" {
return nil, errors.New("language code cannot be empty")
}
return q.repo.GetTranslationByLanguage(ctx, copyrightID, languageCode)
}

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1,27 +1,26 @@
package services package localization
import ( import (
"context" "context"
"errors" "errors"
"tercul/internal/models" "tercul/internal/domain"
"tercul/internal/repositories"
) )
// LocalizationService resolves localized attributes using translations // Service resolves localized attributes using translations
type LocalizationService interface { type Service interface {
GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error)
GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error)
} }
type localizationService struct { type service struct {
translationRepo repositories.TranslationRepository translationRepo domain.TranslationRepository
} }
func NewLocalizationService(translationRepo repositories.TranslationRepository) LocalizationService { func NewService(translationRepo domain.TranslationRepository) Service {
return &localizationService{translationRepo: translationRepo} return &service{translationRepo: translationRepo}
} }
func (s *localizationService) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) { func (s *service) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) {
if workID == 0 { if workID == 0 {
return "", errors.New("invalid work ID") return "", errors.New("invalid work ID")
} }
@ -32,7 +31,7 @@ func (s *localizationService) GetWorkContent(ctx context.Context, workID uint, p
return pickContent(translations, preferredLanguage), nil return pickContent(translations, preferredLanguage), nil
} }
func (s *localizationService) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) { func (s *service) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) {
if authorID == 0 { if authorID == 0 {
return "", errors.New("invalid author ID") return "", errors.New("invalid author ID")
} }
@ -41,7 +40,7 @@ func (s *localizationService) GetAuthorBiography(ctx context.Context, authorID u
return "", err return "", err
} }
// Prefer Description from Translation as biography proxy // Prefer Description from Translation as biography proxy
var byLang *models.Translation var byLang *domain.Translation
for i := range translations { for i := range translations {
tr := &translations[i] tr := &translations[i]
if tr.IsOriginalLanguage && tr.Description != "" { if tr.IsOriginalLanguage && tr.Description != "" {
@ -63,8 +62,8 @@ func (s *localizationService) GetAuthorBiography(ctx context.Context, authorID u
return "", nil return "", nil
} }
func pickContent(translations []models.Translation, preferredLanguage string) string { func pickContent(translations []domain.Translation, preferredLanguage string) string {
var byLang *models.Translation var byLang *domain.Translation
for i := range translations { for i := range translations {
tr := &translations[i] tr := &translations[i]
if tr.IsOriginalLanguage { if tr.IsOriginalLanguage {

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1,29 +1,29 @@
package services package search
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log"
"tercul/internal/models" "tercul/internal/app/localization"
"tercul/internal/domain"
"tercul/internal/platform/search" "tercul/internal/platform/search"
"tercul/internal/repositories"
) )
// SearchIndexService pushes localized snapshots into Weaviate for search // IndexService pushes localized snapshots into Weaviate for search
type SearchIndexService interface { type IndexService interface {
IndexWork(ctx context.Context, work models.Work) error IndexWork(ctx context.Context, work domain.Work) error
} }
type searchIndexService struct { type indexService struct {
localization LocalizationService localization localization.Service
translations repositories.TranslationRepository translations domain.TranslationRepository
} }
func NewSearchIndexService(localization LocalizationService, translations repositories.TranslationRepository) SearchIndexService { func NewIndexService(localization localization.Service, translations domain.TranslationRepository) IndexService {
return &searchIndexService{localization: localization, translations: translations} return &indexService{localization: localization, translations: translations}
} }
func (s *searchIndexService) IndexWork(ctx context.Context, work models.Work) error { func (s *indexService) IndexWork(ctx context.Context, work domain.Work) error {
// Choose best content snapshot for indexing // Choose best content snapshot for indexing
content, err := s.localization.GetWorkContent(ctx, work.ID, work.Language) content, err := s.localization.GetWorkContent(ctx, work.ID, work.Language)
if err != nil { if err != nil {

1
internal/app/work/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1,72 @@
package work
import (
"context"
"errors"
"tercul/internal/domain"
)
// WorkCommands contains the command handlers for the work aggregate.
type WorkCommands struct {
repo domain.WorkRepository
analyzer interface { // This will be replaced with a proper interface later
AnalyzeWork(ctx context.Context, workID uint) error
}
}
// NewWorkCommands creates a new WorkCommands handler.
func NewWorkCommands(repo domain.WorkRepository, analyzer interface {
AnalyzeWork(ctx context.Context, workID uint) error
}) *WorkCommands {
return &WorkCommands{
repo: repo,
analyzer: analyzer,
}
}
// CreateWork creates a new work.
func (c *WorkCommands) CreateWork(ctx context.Context, work *domain.Work) error {
if work == nil {
return errors.New("work cannot be nil")
}
if work.Title == "" {
return errors.New("work title cannot be empty")
}
if work.Language == "" {
return errors.New("work language cannot be empty")
}
return c.repo.Create(ctx, work)
}
// UpdateWork updates an existing work.
func (c *WorkCommands) UpdateWork(ctx context.Context, work *domain.Work) error {
if work == nil {
return errors.New("work cannot be nil")
}
if work.ID == 0 {
return errors.New("work ID cannot be zero")
}
if work.Title == "" {
return errors.New("work title cannot be empty")
}
if work.Language == "" {
return errors.New("work language cannot be empty")
}
return c.repo.Update(ctx, work)
}
// DeleteWork deletes a work by ID.
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
if id == 0 {
return errors.New("invalid work ID")
}
return c.repo.Delete(ctx, id)
}
// AnalyzeWork performs linguistic analysis on a work.
func (c *WorkCommands) AnalyzeWork(ctx context.Context, workID uint) error {
if workID == 0 {
return errors.New("invalid work ID")
}
return c.analyzer.AnalyzeWork(ctx, workID)
}

View File

@ -0,0 +1,94 @@
package work
import (
"context"
"errors"
"tercul/internal/domain"
)
// WorkAnalytics contains analytics data for a work
type WorkAnalytics struct {
WorkID uint
ViewCount int64
LikeCount int64
CommentCount int64
BookmarkCount int64
TranslationCount int64
ReadabilityScore float64
SentimentScore float64
TopKeywords []string
PopularTranslations []TranslationAnalytics
}
// TranslationAnalytics contains analytics data for a translation
type TranslationAnalytics struct {
TranslationID uint
Language string
ViewCount int64
LikeCount int64
}
// WorkQueries contains the query handlers for the work aggregate.
type WorkQueries struct {
repo domain.WorkRepository
}
// NewWorkQueries creates a new WorkQueries handler.
func NewWorkQueries(repo domain.WorkRepository) *WorkQueries {
return &WorkQueries{
repo: repo,
}
}
// GetWorkByID retrieves a work by ID.
func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*domain.Work, error) {
if id == 0 {
return nil, errors.New("invalid work ID")
}
return q.repo.GetByID(ctx, id)
}
// ListWorks returns a paginated list of works.
func (q *WorkQueries) ListWorks(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
return q.repo.List(ctx, page, pageSize)
}
// GetWorkWithTranslations retrieves a work with its translations.
func (q *WorkQueries) GetWorkWithTranslations(ctx context.Context, id uint) (*domain.Work, error) {
if id == 0 {
return nil, errors.New("invalid work ID")
}
return q.repo.GetWithTranslations(ctx, id)
}
// FindWorksByTitle finds works by title.
func (q *WorkQueries) FindWorksByTitle(ctx context.Context, title string) ([]domain.Work, error) {
if title == "" {
return nil, errors.New("title cannot be empty")
}
return q.repo.FindByTitle(ctx, title)
}
// FindWorksByAuthor finds works by author ID.
func (q *WorkQueries) FindWorksByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) {
if authorID == 0 {
return nil, errors.New("invalid author ID")
}
return q.repo.FindByAuthor(ctx, authorID)
}
// FindWorksByCategory finds works by category ID.
func (q *WorkQueries) FindWorksByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) {
if categoryID == 0 {
return nil, errors.New("invalid category ID")
}
return q.repo.FindByCategory(ctx, categoryID)
}
// FindWorksByLanguage finds works by language.
func (q *WorkQueries) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if language == "" {
return nil, errors.New("language cannot be empty")
}
return q.repo.FindByLanguage(ctx, language, page, pageSize)
}

1
internal/data/cache/.keep vendored Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1
internal/data/sql/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1,35 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// AuthorRepository defines CRUD methods specific to Author.
type AuthorRepository interface {
BaseRepository[models.Author]
ListByWorkID(ctx context.Context, workID uint) ([]models.Author, error)
ListByBookID(ctx context.Context, bookID uint) ([]models.Author, error)
ListByCountryID(ctx context.Context, countryID uint) ([]models.Author, error)
}
type authorRepository struct { type authorRepository struct {
BaseRepository[models.Author] domain.BaseRepository[domain.Author]
db *gorm.DB db *gorm.DB
} }
// NewAuthorRepository creates a new AuthorRepository. // NewAuthorRepository creates a new AuthorRepository.
func NewAuthorRepository(db *gorm.DB) AuthorRepository { func NewAuthorRepository(db *gorm.DB) domain.AuthorRepository {
return &authorRepository{ return &authorRepository{
BaseRepository: NewBaseRepositoryImpl[models.Author](db), BaseRepository: NewBaseRepositoryImpl[domain.Author](db),
db: db, db: db,
} }
} }
// ListByWorkID finds authors by work ID // ListByWorkID finds authors by work ID
func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Author, error) { func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) {
var authors []models.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.author_id = authors.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.author_id = authors.id").
Where("work_authors.work_id = ?", workID). Where("work_authors.work_id = ?", workID).
Find(&authors).Error; err != nil { Find(&authors).Error; err != nil {
@ -39,8 +31,8 @@ func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]mod
} }
// ListByBookID finds authors by book ID // ListByBookID finds authors by book ID
func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]models.Author, error) { func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) {
var authors []models.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.author_id = authors.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.author_id = authors.id").
Where("book_authors.book_id = ?", bookID). Where("book_authors.book_id = ?", bookID).
Find(&authors).Error; err != nil { Find(&authors).Error; err != nil {
@ -50,8 +42,8 @@ func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]mod
} }
// ListByCountryID finds authors by country ID // ListByCountryID finds authors by country ID
func (r *authorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]models.Author, error) { func (r *authorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) {
var authors []models.Author var authors []domain.Author
if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&authors).Error; err != nil { if err := r.db.WithContext(ctx).Where("country_id = ?", countryID).Find(&authors).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -1,14 +1,15 @@
package repositories package sql
import ( import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"tercul/internal/domain"
"tercul/internal/platform/config"
"tercul/internal/platform/log"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/platform/config"
"tercul/internal/platform/log"
) )
// Common repository errors // Common repository errors
@ -21,90 +22,13 @@ var (
ErrTransactionFailed = errors.New("transaction failed") ErrTransactionFailed = errors.New("transaction failed")
) )
// PaginatedResult represents a paginated result set
type PaginatedResult[T any] struct {
Items []T `json:"items"`
TotalCount int64 `json:"totalCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
TotalPages int `json:"totalPages"`
HasNext bool `json:"hasNext"`
HasPrev bool `json:"hasPrev"`
}
// QueryOptions provides options for repository queries
type QueryOptions struct {
Preloads []string
OrderBy string
Where map[string]interface{}
Limit int
Offset int
}
// BaseRepository defines common CRUD operations that all repositories should implement
type BaseRepository[T any] interface {
// Create adds a new entity to the database
Create(ctx context.Context, entity *T) error
// CreateInTx creates an entity within a transaction
CreateInTx(ctx context.Context, tx *gorm.DB, entity *T) error
// GetByID retrieves an entity by its ID
GetByID(ctx context.Context, id uint) (*T, error)
// GetByIDWithOptions retrieves an entity by its ID with query options
GetByIDWithOptions(ctx context.Context, id uint, options *QueryOptions) (*T, error)
// Update updates an existing entity
Update(ctx context.Context, entity *T) error
// UpdateInTx updates an entity within a transaction
UpdateInTx(ctx context.Context, tx *gorm.DB, entity *T) error
// Delete removes an entity by its ID
Delete(ctx context.Context, id uint) error
// DeleteInTx removes an entity by its ID within a transaction
DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error
// List returns a paginated list of entities
List(ctx context.Context, page, pageSize int) (*PaginatedResult[T], error)
// ListWithOptions returns entities with query options
ListWithOptions(ctx context.Context, options *QueryOptions) ([]T, error)
// ListAll returns all entities (use with caution for large datasets)
ListAll(ctx context.Context) ([]T, error)
// Count returns the total number of entities
Count(ctx context.Context) (int64, error)
// CountWithOptions returns the count with query options
CountWithOptions(ctx context.Context, options *QueryOptions) (int64, error)
// FindWithPreload retrieves an entity by its ID with preloaded relationships
FindWithPreload(ctx context.Context, preloads []string, id uint) (*T, error)
// GetAllForSync returns entities in batches for synchronization
GetAllForSync(ctx context.Context, batchSize, offset int) ([]T, error)
// Exists checks if an entity exists by ID
Exists(ctx context.Context, id uint) (bool, error)
// BeginTx starts a new transaction
BeginTx(ctx context.Context) (*gorm.DB, error)
// WithTx executes a function within a transaction
WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error
}
// BaseRepositoryImpl provides a default implementation of BaseRepository using GORM // BaseRepositoryImpl provides a default implementation of BaseRepository using GORM
type BaseRepositoryImpl[T any] struct { type BaseRepositoryImpl[T any] struct {
db *gorm.DB db *gorm.DB
} }
// NewBaseRepositoryImpl creates a new BaseRepositoryImpl // NewBaseRepositoryImpl creates a new BaseRepositoryImpl
func NewBaseRepositoryImpl[T any](db *gorm.DB) *BaseRepositoryImpl[T] { func NewBaseRepositoryImpl[T any](db *gorm.DB) domain.BaseRepository[T] {
return &BaseRepositoryImpl[T]{db: db} return &BaseRepositoryImpl[T]{db: db}
} }
@ -153,7 +77,7 @@ func (r *BaseRepositoryImpl[T]) validatePagination(page, pageSize int) (int, int
} }
// buildQuery applies query options to a GORM query // buildQuery applies query options to a GORM query
func (r *BaseRepositoryImpl[T]) buildQuery(query *gorm.DB, options *QueryOptions) *gorm.DB { func (r *BaseRepositoryImpl[T]) buildQuery(query *gorm.DB, options *domain.QueryOptions) *gorm.DB {
if options == nil { if options == nil {
return query return query
} }
@ -272,7 +196,7 @@ func (r *BaseRepositoryImpl[T]) GetByID(ctx context.Context, id uint) (*T, error
} }
// GetByIDWithOptions retrieves an entity by its ID with query options // GetByIDWithOptions retrieves an entity by its ID with query options
func (r *BaseRepositoryImpl[T]) GetByIDWithOptions(ctx context.Context, id uint, options *QueryOptions) (*T, error) { func (r *BaseRepositoryImpl[T]) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*T, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
@ -435,7 +359,7 @@ func (r *BaseRepositoryImpl[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id
} }
// List returns a paginated list of entities // List returns a paginated list of entities
func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*PaginatedResult[T], error) { func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[T], error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
@ -490,7 +414,7 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
log.F("hasPrev", hasPrev), log.F("hasPrev", hasPrev),
log.F("duration", duration)) log.F("duration", duration))
return &PaginatedResult[T]{ return &domain.PaginatedResult[T]{
Items: entities, Items: entities,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
@ -502,7 +426,7 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (*
} }
// ListWithOptions returns entities with query options // ListWithOptions returns entities with query options
func (r *BaseRepositoryImpl[T]) ListWithOptions(ctx context.Context, options *QueryOptions) ([]T, error) { func (r *BaseRepositoryImpl[T]) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]T, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return nil, err return nil, err
} }
@ -573,7 +497,7 @@ func (r *BaseRepositoryImpl[T]) Count(ctx context.Context) (int64, error) {
} }
// CountWithOptions returns the count with query options // CountWithOptions returns the count with query options
func (r *BaseRepositoryImpl[T]) CountWithOptions(ctx context.Context, options *QueryOptions) (int64, error) { func (r *BaseRepositoryImpl[T]) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
if err := r.validateContext(ctx); err != nil { if err := r.validateContext(ctx); err != nil {
return 0, err return 0, err
} }

View File

@ -1,37 +1,28 @@
package repositories package sql
import ( import (
"context" "context"
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// BookRepository defines CRUD methods specific to Book.
type BookRepository interface {
BaseRepository[models.Book]
ListByAuthorID(ctx context.Context, authorID uint) ([]models.Book, error)
ListByPublisherID(ctx context.Context, publisherID uint) ([]models.Book, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Book, error)
FindByISBN(ctx context.Context, isbn string) (*models.Book, error)
}
type bookRepository struct { type bookRepository struct {
BaseRepository[models.Book] domain.BaseRepository[domain.Book]
db *gorm.DB db *gorm.DB
} }
// NewBookRepository creates a new BookRepository. // NewBookRepository creates a new BookRepository.
func NewBookRepository(db *gorm.DB) BookRepository { func NewBookRepository(db *gorm.DB) domain.BookRepository {
return &bookRepository{ return &bookRepository{
BaseRepository: NewBaseRepositoryImpl[models.Book](db), BaseRepository: NewBaseRepositoryImpl[domain.Book](db),
db: db, db: db,
} }
} }
// ListByAuthorID finds books by author ID // ListByAuthorID finds books by author ID
func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]models.Book, error) { func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]domain.Book, error) {
var books []models.Book var books []domain.Book
if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.book_id = books.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_authors ON book_authors.book_id = books.id").
Where("book_authors.author_id = ?", authorID). Where("book_authors.author_id = ?", authorID).
Find(&books).Error; err != nil { Find(&books).Error; err != nil {
@ -41,8 +32,8 @@ func (r *bookRepository) ListByAuthorID(ctx context.Context, authorID uint) ([]m
} }
// ListByPublisherID finds books by publisher ID // ListByPublisherID finds books by publisher ID
func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint) ([]models.Book, error) { func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint) ([]domain.Book, error) {
var books []models.Book var books []domain.Book
if err := r.db.WithContext(ctx).Where("publisher_id = ?", publisherID).Find(&books).Error; err != nil { if err := r.db.WithContext(ctx).Where("publisher_id = ?", publisherID).Find(&books).Error; err != nil {
return nil, err return nil, err
} }
@ -50,8 +41,8 @@ func (r *bookRepository) ListByPublisherID(ctx context.Context, publisherID uint
} }
// ListByWorkID finds books by work ID // ListByWorkID finds books by work ID
func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Book, error) { func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Book, error) {
var books []models.Book var books []domain.Book
if err := r.db.WithContext(ctx).Joins("JOIN book_works ON book_works.book_id = books.id"). if err := r.db.WithContext(ctx).Joins("JOIN book_works ON book_works.book_id = books.id").
Where("book_works.work_id = ?", workID). Where("book_works.work_id = ?", workID).
Find(&books).Error; err != nil { Find(&books).Error; err != nil {
@ -61,8 +52,8 @@ func (r *bookRepository) ListByWorkID(ctx context.Context, workID uint) ([]model
} }
// FindByISBN finds a book by ISBN // FindByISBN finds a book by ISBN
func (r *bookRepository) FindByISBN(ctx context.Context, isbn string) (*models.Book, error) { func (r *bookRepository) FindByISBN(ctx context.Context, isbn string) (*domain.Book, error) {
var book models.Book var book domain.Book
if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&book).Error; err != nil { if err := r.db.WithContext(ctx).Where("isbn = ?", isbn).First(&book).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound return nil, ErrEntityNotFound

View File

@ -1,34 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// BookmarkRepository defines CRUD methods specific to Bookmark.
type BookmarkRepository interface {
BaseRepository[models.Bookmark]
ListByUserID(ctx context.Context, userID uint) ([]models.Bookmark, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Bookmark, error)
}
type bookmarkRepository struct { type bookmarkRepository struct {
BaseRepository[models.Bookmark] domain.BaseRepository[domain.Bookmark]
db *gorm.DB db *gorm.DB
} }
// NewBookmarkRepository creates a new BookmarkRepository. // NewBookmarkRepository creates a new BookmarkRepository.
func NewBookmarkRepository(db *gorm.DB) BookmarkRepository { func NewBookmarkRepository(db *gorm.DB) domain.BookmarkRepository {
return &bookmarkRepository{ return &bookmarkRepository{
BaseRepository: NewBaseRepositoryImpl[models.Bookmark](db), BaseRepository: NewBaseRepositoryImpl[domain.Bookmark](db),
db: db, db: db,
} }
} }
// ListByUserID finds bookmarks by user ID // ListByUserID finds bookmarks by user ID
func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]models.Bookmark, error) { func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) {
var bookmarks []models.Bookmark var bookmarks []domain.Bookmark
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&bookmarks).Error; err != nil {
return nil, err return nil, err
} }
@ -36,8 +29,8 @@ func (r *bookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]m
} }
// ListByWorkID finds bookmarks by work ID // ListByWorkID finds bookmarks by work ID
func (r *bookmarkRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Bookmark, error) { func (r *bookmarkRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) {
var bookmarks []models.Bookmark var bookmarks []domain.Bookmark
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&bookmarks).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&bookmarks).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -1,36 +1,28 @@
package repositories package sql
import ( import (
"context" "context"
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// CategoryRepository defines CRUD methods specific to Category.
type CategoryRepository interface {
BaseRepository[models.Category]
FindByName(ctx context.Context, name string) (*models.Category, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Category, error)
ListByParentID(ctx context.Context, parentID *uint) ([]models.Category, error)
}
type categoryRepository struct { type categoryRepository struct {
BaseRepository[models.Category] domain.BaseRepository[domain.Category]
db *gorm.DB db *gorm.DB
} }
// NewCategoryRepository creates a new CategoryRepository. // NewCategoryRepository creates a new CategoryRepository.
func NewCategoryRepository(db *gorm.DB) CategoryRepository { func NewCategoryRepository(db *gorm.DB) domain.CategoryRepository {
return &categoryRepository{ return &categoryRepository{
BaseRepository: NewBaseRepositoryImpl[models.Category](db), BaseRepository: NewBaseRepositoryImpl[domain.Category](db),
db: db, db: db,
} }
} }
// FindByName finds a category by name // FindByName finds a category by name
func (r *categoryRepository) FindByName(ctx context.Context, name string) (*models.Category, error) { func (r *categoryRepository) FindByName(ctx context.Context, name string) (*domain.Category, error) {
var category models.Category var category domain.Category
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&category).Error; err != nil { if err := r.db.WithContext(ctx).Where("name = ?", name).First(&category).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
@ -41,8 +33,8 @@ func (r *categoryRepository) FindByName(ctx context.Context, name string) (*mode
} }
// ListByWorkID finds categories by work ID // ListByWorkID finds categories by work ID
func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Category, error) { func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) {
var categories []models.Category var categories []domain.Category
if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.category_id = categories.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.category_id = categories.id").
Where("work_categories.work_id = ?", workID). Where("work_categories.work_id = ?", workID).
Find(&categories).Error; err != nil { Find(&categories).Error; err != nil {
@ -52,8 +44,8 @@ func (r *categoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]m
} }
// ListByParentID finds categories by parent ID // ListByParentID finds categories by parent ID
func (r *categoryRepository) ListByParentID(ctx context.Context, parentID *uint) ([]models.Category, error) { func (r *categoryRepository) ListByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) {
var categories []models.Category var categories []domain.Category
if parentID == nil { if parentID == nil {
if err := r.db.WithContext(ctx).Where("parent_id IS NULL").Find(&categories).Error; err != nil { if err := r.db.WithContext(ctx).Where("parent_id IS NULL").Find(&categories).Error; err != nil {
return nil, err return nil, err

View File

@ -1,35 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// CollectionRepository defines CRUD methods specific to Collection.
type CollectionRepository interface {
BaseRepository[models.Collection]
ListByUserID(ctx context.Context, userID uint) ([]models.Collection, error)
ListPublic(ctx context.Context) ([]models.Collection, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Collection, error)
}
type collectionRepository struct { type collectionRepository struct {
BaseRepository[models.Collection] domain.BaseRepository[domain.Collection]
db *gorm.DB db *gorm.DB
} }
// NewCollectionRepository creates a new CollectionRepository. // NewCollectionRepository creates a new CollectionRepository.
func NewCollectionRepository(db *gorm.DB) CollectionRepository { func NewCollectionRepository(db *gorm.DB) domain.CollectionRepository {
return &collectionRepository{ return &collectionRepository{
BaseRepository: NewBaseRepositoryImpl[models.Collection](db), BaseRepository: NewBaseRepositoryImpl[domain.Collection](db),
db: db, db: db,
} }
} }
// ListByUserID finds collections by user ID // ListByUserID finds collections by user ID
func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([]models.Collection, error) { func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) {
var collections []models.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&collections).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&collections).Error; err != nil {
return nil, err return nil, err
} }
@ -37,8 +29,8 @@ func (r *collectionRepository) ListByUserID(ctx context.Context, userID uint) ([
} }
// ListPublic finds public collections // ListPublic finds public collections
func (r *collectionRepository) ListPublic(ctx context.Context) ([]models.Collection, error) { func (r *collectionRepository) ListPublic(ctx context.Context) ([]domain.Collection, error) {
var collections []models.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&collections).Error; err != nil { if err := r.db.WithContext(ctx).Where("is_public = ?", true).Find(&collections).Error; err != nil {
return nil, err return nil, err
} }
@ -46,8 +38,8 @@ func (r *collectionRepository) ListPublic(ctx context.Context) ([]models.Collect
} }
// ListByWorkID finds collections by work ID // ListByWorkID finds collections by work ID
func (r *collectionRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Collection, error) { func (r *collectionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) {
var collections []models.Collection var collections []domain.Collection
if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.collection_id = collections.id"). if err := r.db.WithContext(ctx).Joins("JOIN collection_works ON collection_works.collection_id = collections.id").
Where("collection_works.work_id = ?", workID). Where("collection_works.work_id = ?", workID).
Find(&collections).Error; err != nil { Find(&collections).Error; err != nil {

View File

@ -1,36 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// CommentRepository defines CRUD methods specific to Comment.
type CommentRepository interface {
BaseRepository[models.Comment]
ListByUserID(ctx context.Context, userID uint) ([]models.Comment, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Comment, error)
ListByTranslationID(ctx context.Context, translationID uint) ([]models.Comment, error)
ListByParentID(ctx context.Context, parentID uint) ([]models.Comment, error)
}
type commentRepository struct { type commentRepository struct {
BaseRepository[models.Comment] domain.BaseRepository[domain.Comment]
db *gorm.DB db *gorm.DB
} }
// NewCommentRepository creates a new CommentRepository. // NewCommentRepository creates a new CommentRepository.
func NewCommentRepository(db *gorm.DB) CommentRepository { func NewCommentRepository(db *gorm.DB) domain.CommentRepository {
return &commentRepository{ return &commentRepository{
BaseRepository: NewBaseRepositoryImpl[models.Comment](db), BaseRepository: NewBaseRepositoryImpl[domain.Comment](db),
db: db, db: db,
} }
} }
// ListByUserID finds comments by user ID // ListByUserID finds comments by user ID
func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]models.Comment, error) { func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) {
var comments []models.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&comments).Error; err != nil {
return nil, err return nil, err
} }
@ -38,8 +29,8 @@ func (r *commentRepository) ListByUserID(ctx context.Context, userID uint) ([]mo
} }
// ListByWorkID finds comments by work ID // ListByWorkID finds comments by work ID
func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Comment, error) { func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) {
var comments []models.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&comments).Error; err != nil {
return nil, err return nil, err
} }
@ -47,8 +38,8 @@ func (r *commentRepository) ListByWorkID(ctx context.Context, workID uint) ([]mo
} }
// ListByTranslationID finds comments by translation ID // ListByTranslationID finds comments by translation ID
func (r *commentRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]models.Comment, error) { func (r *commentRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) {
var comments []models.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&comments).Error; err != nil {
return nil, err return nil, err
} }
@ -56,8 +47,8 @@ func (r *commentRepository) ListByTranslationID(ctx context.Context, translation
} }
// ListByParentID finds comments by parent ID // ListByParentID finds comments by parent ID
func (r *commentRepository) ListByParentID(ctx context.Context, parentID uint) ([]models.Comment, error) { func (r *commentRepository) ListByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) {
var comments []models.Comment var comments []domain.Comment
if err := r.db.WithContext(ctx).Where("parent_id = ?", parentID).Find(&comments).Error; err != nil { if err := r.db.WithContext(ctx).Where("parent_id = ?", parentID).Find(&comments).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,93 @@
package repositories
import (
"context"
"errors"
"gorm.io/gorm"
"tercul/internal/models"
)
// CopyrightRepository defines CRUD methods specific to Copyright.
type CopyrightRepository interface {
BaseRepository[models.Copyright]
// Polymorphic methods
AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error
DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error
GetByEntity(ctx context.Context, entityID uint, entityType string) ([]models.Copyright, error)
GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]models.Copyrightable, error)
// Translation methods
AddTranslation(ctx context.Context, translation *models.CopyrightTranslation) error
GetTranslations(ctx context.Context, copyrightID uint) ([]models.CopyrightTranslation, error)
GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*models.CopyrightTranslation, error)
}
type copyrightRepository struct {
BaseRepository[models.Copyright]
db *gorm.DB
}
// NewCopyrightRepository creates a new CopyrightRepository.
func NewCopyrightRepository(db *gorm.DB) CopyrightRepository {
return &copyrightRepository{
BaseRepository: NewBaseRepositoryImpl[models.Copyright](db),
db: db,
}
}
// AttachToEntity attaches a copyright to any entity type
func (r *copyrightRepository) AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error {
copyrightable := models.Copyrightable{
CopyrightID: copyrightID,
CopyrightableID: entityID,
CopyrightableType: entityType,
}
return r.db.WithContext(ctx).Create(&copyrightable).Error
}
// DetachFromEntity removes a copyright from an entity
func (r *copyrightRepository) DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error {
return r.db.WithContext(ctx).Where("copyright_id = ? AND copyrightable_id = ? AND copyrightable_type = ?",
copyrightID, entityID, entityType).Delete(&models.Copyrightable{}).Error
}
// GetByEntity gets all copyrights for a specific entity
func (r *copyrightRepository) GetByEntity(ctx context.Context, entityID uint, entityType string) ([]models.Copyright, error) {
var copyrights []models.Copyright
err := r.db.WithContext(ctx).Joins("JOIN copyrightables ON copyrightables.copyright_id = copyrights.id").
Where("copyrightables.copyrightable_id = ? AND copyrightables.copyrightable_type = ?", entityID, entityType).
Preload("Translations").
Find(&copyrights).Error
return copyrights, err
}
// GetEntitiesByCopyright gets all entities that have a specific copyright
func (r *copyrightRepository) GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]models.Copyrightable, error) {
var copyrightables []models.Copyrightable
err := r.db.WithContext(ctx).Where("copyright_id = ?", copyrightID).Find(&copyrightables).Error
return copyrightables, err
}
// AddTranslation adds a translation to a copyright
func (r *copyrightRepository) AddTranslation(ctx context.Context, translation *models.CopyrightTranslation) error {
return r.db.WithContext(ctx).Create(translation).Error
}
// GetTranslations gets all translations for a copyright
func (r *copyrightRepository) GetTranslations(ctx context.Context, copyrightID uint) ([]models.CopyrightTranslation, error) {
var translations []models.CopyrightTranslation
err := r.db.WithContext(ctx).Where("copyright_id = ?", copyrightID).Find(&translations).Error
return translations, err
}
// GetTranslationByLanguage gets a specific translation by language code
func (r *copyrightRepository) GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*models.CopyrightTranslation, error) {
var translation models.CopyrightTranslation
err := r.db.WithContext(ctx).Where("copyright_id = ? AND language_code = ?", copyrightID, languageCode).First(&translation).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound
}
return nil, err
}
return &translation, nil
}

View File

@ -1,36 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// LikeRepository defines CRUD methods specific to Like.
type LikeRepository interface {
BaseRepository[models.Like]
ListByUserID(ctx context.Context, userID uint) ([]models.Like, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Like, error)
ListByTranslationID(ctx context.Context, translationID uint) ([]models.Like, error)
ListByCommentID(ctx context.Context, commentID uint) ([]models.Like, error)
}
type likeRepository struct { type likeRepository struct {
BaseRepository[models.Like] domain.BaseRepository[domain.Like]
db *gorm.DB db *gorm.DB
} }
// NewLikeRepository creates a new LikeRepository. // NewLikeRepository creates a new LikeRepository.
func NewLikeRepository(db *gorm.DB) LikeRepository { func NewLikeRepository(db *gorm.DB) domain.LikeRepository {
return &likeRepository{ return &likeRepository{
BaseRepository: NewBaseRepositoryImpl[models.Like](db), BaseRepository: NewBaseRepositoryImpl[domain.Like](db),
db: db, db: db,
} }
} }
// ListByUserID finds likes by user ID // ListByUserID finds likes by user ID
func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]models.Like, error) { func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) {
var likes []models.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&likes).Error; err != nil {
return nil, err return nil, err
} }
@ -38,8 +29,8 @@ func (r *likeRepository) ListByUserID(ctx context.Context, userID uint) ([]model
} }
// ListByWorkID finds likes by work ID // ListByWorkID finds likes by work ID
func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Like, error) { func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) {
var likes []models.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("work_id = ?", workID).Find(&likes).Error; err != nil {
return nil, err return nil, err
} }
@ -47,8 +38,8 @@ func (r *likeRepository) ListByWorkID(ctx context.Context, workID uint) ([]model
} }
// ListByTranslationID finds likes by translation ID // ListByTranslationID finds likes by translation ID
func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]models.Like, error) { func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) {
var likes []models.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("translation_id = ?", translationID).Find(&likes).Error; err != nil {
return nil, err return nil, err
} }
@ -56,8 +47,8 @@ func (r *likeRepository) ListByTranslationID(ctx context.Context, translationID
} }
// ListByCommentID finds likes by comment ID // ListByCommentID finds likes by comment ID
func (r *likeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]models.Like, error) { func (r *likeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) {
var likes []models.Like var likes []domain.Like
if err := r.db.WithContext(ctx).Where("comment_id = ?", commentID).Find(&likes).Error; err != nil { if err := r.db.WithContext(ctx).Where("comment_id = ?", commentID).Find(&likes).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -1,35 +1,28 @@
package repositories package sql
import ( import (
"context" "context"
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// TagRepository defines CRUD methods specific to Tag.
type TagRepository interface {
BaseRepository[models.Tag]
FindByName(ctx context.Context, name string) (*models.Tag, error)
ListByWorkID(ctx context.Context, workID uint) ([]models.Tag, error)
}
type tagRepository struct { type tagRepository struct {
BaseRepository[models.Tag] domain.BaseRepository[domain.Tag]
db *gorm.DB db *gorm.DB
} }
// NewTagRepository creates a new TagRepository. // NewTagRepository creates a new TagRepository.
func NewTagRepository(db *gorm.DB) TagRepository { func NewTagRepository(db *gorm.DB) domain.TagRepository {
return &tagRepository{ return &tagRepository{
BaseRepository: NewBaseRepositoryImpl[models.Tag](db), BaseRepository: NewBaseRepositoryImpl[domain.Tag](db),
db: db, db: db,
} }
} }
// FindByName finds a tag by name // FindByName finds a tag by name
func (r *tagRepository) FindByName(ctx context.Context, name string) (*models.Tag, error) { func (r *tagRepository) FindByName(ctx context.Context, name string) (*domain.Tag, error) {
var tag models.Tag var tag domain.Tag
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil { if err := r.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
@ -40,8 +33,8 @@ func (r *tagRepository) FindByName(ctx context.Context, name string) (*models.Ta
} }
// ListByWorkID finds tags by work ID // ListByWorkID finds tags by work ID
func (r *tagRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Tag, error) { func (r *tagRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) {
var tags []models.Tag var tags []domain.Tag
if err := r.db.WithContext(ctx).Joins("JOIN work_tags ON work_tags.tag_id = tags.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_tags ON work_tags.tag_id = tags.id").
Where("work_tags.work_id = ?", workID). Where("work_tags.work_id = ?", workID).
Find(&tags).Error; err != nil { Find(&tags).Error; err != nil {

View File

@ -1,36 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
models2 "tercul/internal/models" "tercul/internal/domain"
) )
// TranslationRepository defines CRUD methods specific to Translation.
type TranslationRepository interface {
BaseRepository[models2.Translation]
ListByWorkID(ctx context.Context, workID uint) ([]models2.Translation, error)
ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models2.Translation, error)
ListByTranslatorID(ctx context.Context, translatorID uint) ([]models2.Translation, error)
ListByStatus(ctx context.Context, status models2.TranslationStatus) ([]models2.Translation, error)
}
type translationRepository struct { type translationRepository struct {
BaseRepository[models2.Translation] domain.BaseRepository[domain.Translation]
db *gorm.DB db *gorm.DB
} }
// NewTranslationRepository creates a new TranslationRepository. // NewTranslationRepository creates a new TranslationRepository.
func NewTranslationRepository(db *gorm.DB) TranslationRepository { func NewTranslationRepository(db *gorm.DB) domain.TranslationRepository {
return &translationRepository{ return &translationRepository{
BaseRepository: NewBaseRepositoryImpl[models2.Translation](db), BaseRepository: NewBaseRepositoryImpl[domain.Translation](db),
db: db, db: db,
} }
} }
// ListByWorkID finds translations by work ID // ListByWorkID finds translations by work ID
func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]models2.Translation, error) { func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
var translations []models2.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "Work").Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "Work").Find(&translations).Error; err != nil {
return nil, err return nil, err
} }
@ -38,8 +29,8 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) (
} }
// ListByEntity finds translations by entity type and ID // ListByEntity finds translations by entity type and ID
func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models2.Translation, error) { func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
var translations []models2.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", entityID, entityType).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", entityID, entityType).Find(&translations).Error; err != nil {
return nil, err return nil, err
} }
@ -47,8 +38,8 @@ func (r *translationRepository) ListByEntity(ctx context.Context, entityType str
} }
// ListByTranslatorID finds translations by translator ID // ListByTranslatorID finds translations by translator ID
func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]models2.Translation, error) { func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
var translations []models2.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("translator_id = ?", translatorID).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("translator_id = ?", translatorID).Find(&translations).Error; err != nil {
return nil, err return nil, err
} }
@ -56,8 +47,8 @@ func (r *translationRepository) ListByTranslatorID(ctx context.Context, translat
} }
// ListByStatus finds translations by status // ListByStatus finds translations by status
func (r *translationRepository) ListByStatus(ctx context.Context, status models2.TranslationStatus) ([]models2.Translation, error) { func (r *translationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) {
var translations []models2.Translation var translations []domain.Translation
if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&translations).Error; err != nil { if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&translations).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -1,36 +1,28 @@
package repositories package sql
import ( import (
"context" "context"
"errors" "errors"
"gorm.io/gorm" "gorm.io/gorm"
models2 "tercul/internal/models" "tercul/internal/domain"
) )
// UserRepository defines CRUD methods specific to User.
type UserRepository interface {
BaseRepository[models2.User]
FindByUsername(ctx context.Context, username string) (*models2.User, error)
FindByEmail(ctx context.Context, email string) (*models2.User, error)
ListByRole(ctx context.Context, role models2.UserRole) ([]models2.User, error)
}
type userRepository struct { type userRepository struct {
BaseRepository[models2.User] domain.BaseRepository[domain.User]
db *gorm.DB db *gorm.DB
} }
// NewUserRepository creates a new UserRepository. // NewUserRepository creates a new UserRepository.
func NewUserRepository(db *gorm.DB) UserRepository { func NewUserRepository(db *gorm.DB) domain.UserRepository {
return &userRepository{ return &userRepository{
BaseRepository: NewBaseRepositoryImpl[models2.User](db), BaseRepository: NewBaseRepositoryImpl[domain.User](db),
db: db, db: db,
} }
} }
// FindByUsername finds a user by username // FindByUsername finds a user by username
func (r *userRepository) FindByUsername(ctx context.Context, username string) (*models2.User, error) { func (r *userRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
var user models2.User var user domain.User
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
@ -41,8 +33,8 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (*
} }
// FindByEmail finds a user by email // FindByEmail finds a user by email
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models2.User, error) { func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
var user models2.User var user domain.User
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEntityNotFound return nil, ErrEntityNotFound
@ -53,8 +45,8 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models
} }
// ListByRole lists users by role // ListByRole lists users by role
func (r *userRepository) ListByRole(ctx context.Context, role models2.UserRole) ([]models2.User, error) { func (r *userRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) {
var users []models2.User var users []domain.User
if err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error; err != nil { if err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error; err != nil {
return nil, err return nil, err
} }

View File

@ -1,38 +1,27 @@
package repositories package sql
import ( import (
"context" "context"
"gorm.io/gorm" "gorm.io/gorm"
"tercul/internal/models" "tercul/internal/domain"
) )
// WorkRepository defines methods specific to Work.
type WorkRepository interface {
BaseRepository[models.Work]
FindByTitle(ctx context.Context, title string) ([]models.Work, error)
FindByAuthor(ctx context.Context, authorID uint) ([]models.Work, error)
FindByCategory(ctx context.Context, categoryID uint) ([]models.Work, error)
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*PaginatedResult[models.Work], error)
GetWithTranslations(ctx context.Context, id uint) (*models.Work, error)
ListWithTranslations(ctx context.Context, page, pageSize int) (*PaginatedResult[models.Work], error)
}
type workRepository struct { type workRepository struct {
BaseRepository[models.Work] domain.BaseRepository[domain.Work]
db *gorm.DB db *gorm.DB
} }
// NewWorkRepository creates a new WorkRepository. // NewWorkRepository creates a new WorkRepository.
func NewWorkRepository(db *gorm.DB) WorkRepository { func NewWorkRepository(db *gorm.DB) domain.WorkRepository {
return &workRepository{ return &workRepository{
BaseRepository: NewBaseRepositoryImpl[models.Work](db), BaseRepository: NewBaseRepositoryImpl[domain.Work](db),
db: db, db: db,
} }
} }
// FindByTitle finds works by title (partial match) // FindByTitle finds works by title (partial match)
func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]models.Work, error) { func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) {
var works []models.Work var works []domain.Work
if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil { if err := r.db.WithContext(ctx).Where("title LIKE ?", "%"+title+"%").Find(&works).Error; err != nil {
return nil, err return nil, err
} }
@ -40,8 +29,8 @@ func (r *workRepository) FindByTitle(ctx context.Context, title string) ([]model
} }
// FindByAuthor finds works by author ID // FindByAuthor finds works by author ID
func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]models.Work, error) { func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) {
var works []models.Work var works []domain.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_authors ON work_authors.work_id = works.id").
Where("work_authors.author_id = ?", authorID). Where("work_authors.author_id = ?", authorID).
Find(&works).Error; err != nil { Find(&works).Error; err != nil {
@ -51,8 +40,8 @@ func (r *workRepository) FindByAuthor(ctx context.Context, authorID uint) ([]mod
} }
// FindByCategory finds works by category ID // FindByCategory finds works by category ID
func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]models.Work, error) { func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) {
var works []models.Work var works []domain.Work
if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id"). if err := r.db.WithContext(ctx).Joins("JOIN work_categories ON work_categories.work_id = works.id").
Where("work_categories.category_id = ?", categoryID). Where("work_categories.category_id = ?", categoryID).
Find(&works).Error; err != nil { Find(&works).Error; err != nil {
@ -62,7 +51,7 @@ func (r *workRepository) FindByCategory(ctx context.Context, categoryID uint) ([
} }
// FindByLanguage finds works by language with pagination // FindByLanguage finds works by language with pagination
func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*PaginatedResult[models.Work], error) { func (r *workRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@ -71,11 +60,11 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa
pageSize = 20 pageSize = 20
} }
var works []models.Work var works []domain.Work
var totalCount int64 var totalCount int64
// Get total count // Get total count
if err := r.db.WithContext(ctx).Model(&models.Work{}).Where("language = ?", language).Count(&totalCount).Error; err != nil { if err := r.db.WithContext(ctx).Model(&domain.Work{}).Where("language = ?", language).Count(&totalCount).Error; err != nil {
return nil, err return nil, err
} }
@ -98,7 +87,7 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa
hasNext := page < totalPages hasNext := page < totalPages
hasPrev := page > 1 hasPrev := page > 1
return &PaginatedResult[models.Work]{ return &domain.PaginatedResult[domain.Work]{
Items: works, Items: works,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,
@ -110,12 +99,12 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa
} }
// GetWithTranslations gets a work with its translations // GetWithTranslations gets a work with its translations
func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*models.Work, error) { func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) {
return r.FindWithPreload(ctx, []string{"Translations"}, id) return r.FindWithPreload(ctx, []string{"Translations"}, id)
} }
// ListWithTranslations lists works with their translations // ListWithTranslations lists works with their translations
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*PaginatedResult[models.Work], error) { func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@ -124,11 +113,11 @@ func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSiz
pageSize = 20 pageSize = 20
} }
var works []models.Work var works []domain.Work
var totalCount int64 var totalCount int64
// Get total count // Get total count
if err := r.db.WithContext(ctx).Model(&models.Work{}).Count(&totalCount).Error; err != nil { if err := r.db.WithContext(ctx).Model(&domain.Work{}).Count(&totalCount).Error; err != nil {
return nil, err return nil, err
} }
@ -151,7 +140,7 @@ func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSiz
hasNext := page < totalPages hasNext := page < totalPages
hasPrev := page > 1 hasPrev := page > 1
return &PaginatedResult[models.Work]{ return &domain.PaginatedResult[domain.Work]{
Items: works, Items: works,
TotalCount: totalCount, TotalCount: totalCount,
Page: page, Page: page,

1
internal/domain/.keep Normal file
View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

1053
internal/domain/entities.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
package domain
import (
"context"
"gorm.io/gorm"
)
// PaginatedResult represents a paginated result set
type PaginatedResult[T any] struct {
Items []T `json:"items"`
TotalCount int64 `json:"totalCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
TotalPages int `json:"totalPages"`
HasNext bool `json:"hasNext"`
HasPrev bool `json:"hasPrev"`
}
// QueryOptions provides options for repository queries
type QueryOptions struct {
Preloads []string
OrderBy string
Where map[string]interface{}
Limit int
Offset int
}
// BaseRepository defines common CRUD operations that all repositories should implement
type BaseRepository[T any] interface {
Create(ctx context.Context, entity *T) error
CreateInTx(ctx context.Context, tx *gorm.DB, entity *T) error
GetByID(ctx context.Context, id uint) (*T, error)
GetByIDWithOptions(ctx context.Context, id uint, options *QueryOptions) (*T, error)
Update(ctx context.Context, entity *T) error
UpdateInTx(ctx context.Context, tx *gorm.DB, entity *T) error
Delete(ctx context.Context, id uint) error
DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error
List(ctx context.Context, page, pageSize int) (*PaginatedResult[T], error)
ListWithOptions(ctx context.Context, options *QueryOptions) ([]T, error)
ListAll(ctx context.Context) ([]T, error)
Count(ctx context.Context) (int64, error)
CountWithOptions(ctx context.Context, options *QueryOptions) (int64, error)
FindWithPreload(ctx context.Context, preloads []string, id uint) (*T, error)
GetAllForSync(ctx context.Context, batchSize, offset int) ([]T, error)
Exists(ctx context.Context, id uint) (bool, error)
BeginTx(ctx context.Context) (*gorm.DB, error)
WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error
}
// WorkRepository defines methods specific to Work.
type WorkRepository interface {
BaseRepository[Work]
FindByTitle(ctx context.Context, title string) ([]Work, error)
FindByAuthor(ctx context.Context, authorID uint) ([]Work, error)
FindByCategory(ctx context.Context, categoryID uint) ([]Work, error)
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*PaginatedResult[Work], error)
GetWithTranslations(ctx context.Context, id uint) (*Work, error)
ListWithTranslations(ctx context.Context, page, pageSize int) (*PaginatedResult[Work], error)
}
// AuthorRepository defines CRUD methods specific to Author.
type AuthorRepository interface {
BaseRepository[Author]
ListByWorkID(ctx context.Context, workID uint) ([]Author, error)
ListByBookID(ctx context.Context, bookID uint) ([]Author, error)
ListByCountryID(ctx context.Context, countryID uint) ([]Author, error)
}
// BookRepository defines CRUD methods specific to Book.
type BookRepository interface {
BaseRepository[Book]
ListByAuthorID(ctx context.Context, authorID uint) ([]Book, error)
ListByPublisherID(ctx context.Context, publisherID uint) ([]Book, error)
ListByWorkID(ctx context.Context, workID uint) ([]Book, error)
FindByISBN(ctx context.Context, isbn string) (*Book, error)
}
// UserRepository defines CRUD methods specific to User.
type UserRepository interface {
BaseRepository[User]
FindByUsername(ctx context.Context, username string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
ListByRole(ctx context.Context, role UserRole) ([]User, error)
}
// TranslationRepository defines CRUD methods specific to Translation.
type TranslationRepository interface {
BaseRepository[Translation]
ListByWorkID(ctx context.Context, workID uint) ([]Translation, error)
ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error)
ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error)
ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error)
}
// CommentRepository defines CRUD methods specific to Comment.
type CommentRepository interface {
BaseRepository[Comment]
ListByUserID(ctx context.Context, userID uint) ([]Comment, error)
ListByWorkID(ctx context.Context, workID uint) ([]Comment, error)
ListByTranslationID(ctx context.Context, translationID uint) ([]Comment, error)
ListByParentID(ctx context.Context, parentID uint) ([]Comment, error)
}
// LikeRepository defines CRUD methods specific to Like.
type LikeRepository interface {
BaseRepository[Like]
ListByUserID(ctx context.Context, userID uint) ([]Like, error)
ListByWorkID(ctx context.Context, workID uint) ([]Like, error)
ListByTranslationID(ctx context.Context, translationID uint) ([]Like, error)
ListByCommentID(ctx context.Context, commentID uint) ([]Like, error)
}
// BookmarkRepository defines CRUD methods specific to Bookmark.
type BookmarkRepository interface {
BaseRepository[Bookmark]
ListByUserID(ctx context.Context, userID uint) ([]Bookmark, error)
ListByWorkID(ctx context.Context, workID uint) ([]Bookmark, error)
}
// CollectionRepository defines CRUD methods specific to Collection.
type CollectionRepository interface {
BaseRepository[Collection]
ListByUserID(ctx context.Context, userID uint) ([]Collection, error)
ListPublic(ctx context.Context) ([]Collection, error)
ListByWorkID(ctx context.Context, workID uint) ([]Collection, error)
}
// TagRepository defines CRUD methods specific to Tag.
type TagRepository interface {
BaseRepository[Tag]
FindByName(ctx context.Context, name string) (*Tag, error)
ListByWorkID(ctx context.Context, workID uint) ([]Tag, error)
}
// CategoryRepository defines CRUD methods specific to Category.
type CategoryRepository interface {
BaseRepository[Category]
FindByName(ctx context.Context, name string) (*Category, error)
ListByWorkID(ctx context.Context, workID uint) ([]Category, error)
ListByParentID(ctx context.Context, parentID *uint) ([]Category, error)
}
// CopyrightRepository defines CRUD methods specific to Copyright.
type CopyrightRepository interface {
BaseRepository[Copyright]
AttachToEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error
DetachFromEntity(ctx context.Context, copyrightID uint, entityID uint, entityType string) error
GetByEntity(ctx context.Context, entityID uint, entityType string) ([]Copyright, error)
GetEntitiesByCopyright(ctx context.Context, copyrightID uint) ([]Copyrightable, error)
AddTranslation(ctx context.Context, translation *CopyrightTranslation) error
GetTranslations(ctx context.Context, copyrightID uint) ([]CopyrightTranslation, error)
GetTranslationByLanguage(ctx context.Context, copyrightID uint, languageCode string) (*CopyrightTranslation, error)
}

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -0,0 +1 @@
# This file is created to ensure the directory structure is in place.

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"sort" "sort"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"strings" "strings"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"strings" "strings"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"strings" "strings"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"strings" "strings"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import ( import (
"strings" "strings"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
import "testing" import "testing"

View File

@ -1,4 +1,4 @@
package enrich package linguistics
// Registry holds all the text analysis services // Registry holds all the text analysis services
type Registry struct { type Registry struct {

Some files were not shown because too many files have changed in this diff Show More