mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 04:01:34 +00:00
This commit implements a robust, production-ready analytics system using an event-driven architecture with Redis and `asynq`. Key changes: - Event-Driven Architecture: Instead of synchronous database updates, analytics events (e.g., views, likes, comments) are now published to a Redis queue. This improves API response times and decouples the analytics system from the main application flow. - Background Worker: A new worker process (`cmd/worker`) has been created to consume events from the queue and update the analytics counters in the database. - View Counting: Implemented the missing view counting feature for both works and translations. - New Analytics Query: Added a `popularTranslations` GraphQL query to demonstrate how to use the collected analytics data. - Testing: Added unit tests for the new event publisher and integration tests for the analytics worker. Known Issue: The integration tests for the analytics worker (`AnalyticsWorkerSuite`) and the GraphQL API (`GraphQLIntegrationSuite`) are currently failing due to the lack of a Redis service in the test environment. The tests are written and are expected to pass in an environment where Redis is available on `localhost:6379`, as configured in the CI pipeline.
715 lines
12 KiB
GraphQL
715 lines
12 KiB
GraphQL
# GraphQL schema for Tercul literary platform
|
|
|
|
# Core types
|
|
type Work {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
content: String
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
translations: [Translation!]
|
|
authors: [Author!]
|
|
tags: [Tag!]
|
|
categories: [Category!]
|
|
readabilityScore: ReadabilityScore
|
|
writingStyle: WritingStyle
|
|
emotions: [Emotion!]
|
|
topicClusters: [TopicCluster!]
|
|
moods: [Mood!]
|
|
concepts: [Concept!]
|
|
linguisticLayers: [LinguisticLayer!]
|
|
stats: WorkStats
|
|
textMetadata: TextMetadata
|
|
poeticAnalysis: PoeticAnalysis
|
|
copyright: Copyright
|
|
copyrightClaims: [CopyrightClaim!]
|
|
collections: [Collection!]
|
|
comments: [Comment!]
|
|
likes: [Like!]
|
|
bookmarks: [Bookmark!]
|
|
}
|
|
|
|
type Translation {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
content: String
|
|
workId: ID!
|
|
work: Work!
|
|
translator: User
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
stats: TranslationStats
|
|
copyright: Copyright
|
|
copyrightClaims: [CopyrightClaim!]
|
|
comments: [Comment!]
|
|
likes: [Like!]
|
|
}
|
|
|
|
type Author {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
biography: String
|
|
birthDate: String
|
|
deathDate: String
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
books: [Book!]
|
|
country: Country
|
|
city: City
|
|
place: Place
|
|
address: Address
|
|
copyrightClaims: [CopyrightClaim!]
|
|
copyright: Copyright
|
|
}
|
|
|
|
type User {
|
|
id: ID!
|
|
username: String!
|
|
email: String!
|
|
firstName: String
|
|
lastName: String
|
|
displayName: String
|
|
bio: String
|
|
avatarUrl: String
|
|
role: UserRole!
|
|
lastLoginAt: String
|
|
verified: Boolean!
|
|
active: Boolean!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
|
|
# Relationships
|
|
translations: [Translation!]
|
|
comments: [Comment!]
|
|
likes: [Like!]
|
|
bookmarks: [Bookmark!]
|
|
collections: [Collection!]
|
|
contributions: [Contribution!]
|
|
|
|
# Location
|
|
country: Country
|
|
city: City
|
|
address: Address
|
|
|
|
# Stats
|
|
stats: UserStats
|
|
}
|
|
|
|
type UserProfile {
|
|
id: ID!
|
|
userId: ID!
|
|
user: User!
|
|
phoneNumber: String
|
|
website: String
|
|
twitter: String
|
|
facebook: String
|
|
linkedIn: String
|
|
github: String
|
|
preferences: JSON
|
|
settings: JSON
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
}
|
|
|
|
enum UserRole {
|
|
READER
|
|
CONTRIBUTOR
|
|
REVIEWER
|
|
EDITOR
|
|
ADMIN
|
|
}
|
|
|
|
type Book {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
stats: BookStats
|
|
copyright: Copyright
|
|
copyrightClaims: [CopyrightClaim!]
|
|
}
|
|
|
|
type Collection {
|
|
id: ID!
|
|
name: String!
|
|
description: String
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
user: User
|
|
stats: CollectionStats
|
|
}
|
|
|
|
type Tag {
|
|
id: ID!
|
|
name: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
}
|
|
|
|
type Category {
|
|
id: ID!
|
|
name: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
}
|
|
|
|
type Comment {
|
|
id: ID!
|
|
text: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User!
|
|
work: Work
|
|
translation: Translation
|
|
lineNumber: Int
|
|
parentComment: Comment
|
|
childComments: [Comment!]
|
|
likes: [Like!]
|
|
}
|
|
|
|
type Like {
|
|
id: ID!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User!
|
|
work: Work
|
|
translation: Translation
|
|
comment: Comment
|
|
}
|
|
|
|
type Bookmark {
|
|
id: ID!
|
|
name: String
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User!
|
|
work: Work!
|
|
}
|
|
|
|
type Contribution {
|
|
id: ID!
|
|
name: String!
|
|
status: ContributionStatus!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User!
|
|
work: Work
|
|
translation: Translation
|
|
}
|
|
|
|
enum ContributionStatus {
|
|
DRAFT
|
|
SUBMITTED
|
|
UNDER_REVIEW
|
|
APPROVED
|
|
REJECTED
|
|
}
|
|
|
|
type ReadabilityScore {
|
|
id: ID!
|
|
score: Float!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work
|
|
}
|
|
|
|
type WritingStyle {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work
|
|
}
|
|
|
|
type Emotion {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User
|
|
work: Work
|
|
collection: Collection
|
|
}
|
|
|
|
type TopicCluster {
|
|
id: ID!
|
|
name: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
}
|
|
|
|
type Mood {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
}
|
|
|
|
type Concept {
|
|
id: ID!
|
|
name: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
words: [Word!]
|
|
}
|
|
|
|
type Word {
|
|
id: ID!
|
|
name: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
concept: Concept
|
|
works: [Work!]
|
|
}
|
|
|
|
type LinguisticLayer {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
works: [Work!]
|
|
}
|
|
|
|
type WorkStats {
|
|
id: ID!
|
|
views: Int
|
|
likes: Int
|
|
comments: Int
|
|
bookmarks: Int
|
|
shares: Int
|
|
translationCount: Int
|
|
readingTime: Int
|
|
complexity: Float
|
|
sentiment: Float
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work!
|
|
}
|
|
|
|
type TranslationStats {
|
|
id: ID!
|
|
views: Int
|
|
likes: Int
|
|
comments: Int
|
|
shares: Int
|
|
readingTime: Int
|
|
sentiment: Float
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
translation: Translation!
|
|
}
|
|
|
|
type UserStats {
|
|
id: ID!
|
|
activity: Int!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
user: User!
|
|
}
|
|
|
|
type BookStats {
|
|
id: ID!
|
|
sales: Int!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
book: Book!
|
|
}
|
|
|
|
type CollectionStats {
|
|
id: ID!
|
|
items: Int!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
collection: Collection!
|
|
}
|
|
|
|
type TextMetadata {
|
|
id: ID!
|
|
analysis: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work!
|
|
}
|
|
|
|
type PoeticAnalysis {
|
|
id: ID!
|
|
structure: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work!
|
|
}
|
|
|
|
type Copyright {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
workOwner: Author
|
|
works: [Work!]
|
|
translations: [Translation!]
|
|
books: [Book!]
|
|
sources: [Source!]
|
|
}
|
|
|
|
type CopyrightClaim {
|
|
id: ID!
|
|
details: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
work: Work
|
|
translation: Translation
|
|
book: Book
|
|
source: Source
|
|
author: Author
|
|
user: User
|
|
}
|
|
|
|
type Country {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
authors: [Author!]
|
|
users: [User!]
|
|
}
|
|
|
|
type City {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
country: Country
|
|
authors: [Author!]
|
|
users: [User!]
|
|
}
|
|
|
|
type Place {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
city: City
|
|
country: Country
|
|
authors: [Author!]
|
|
}
|
|
|
|
type Address {
|
|
id: ID!
|
|
street: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
city: City
|
|
country: Country
|
|
authors: [Author!]
|
|
users: [User!]
|
|
}
|
|
|
|
type Source {
|
|
id: ID!
|
|
name: String!
|
|
language: String!
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
copyright: Copyright
|
|
copyrightClaims: [CopyrightClaim!]
|
|
works: [Work!]
|
|
}
|
|
|
|
type Edge {
|
|
id: ID!
|
|
sourceTable: String!
|
|
sourceId: ID!
|
|
targetTable: String!
|
|
targetId: ID!
|
|
relation: String!
|
|
language: String
|
|
extra: JSON
|
|
createdAt: String!
|
|
updatedAt: String!
|
|
}
|
|
|
|
scalar JSON
|
|
|
|
directive @binding(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
|
|
|
|
# Queries
|
|
type Query {
|
|
# Work queries
|
|
work(id: ID!): Work
|
|
works(
|
|
limit: Int
|
|
offset: Int
|
|
language: String
|
|
authorId: ID
|
|
categoryId: ID
|
|
tagId: ID
|
|
search: String
|
|
): [Work!]!
|
|
|
|
# Translation queries
|
|
translation(id: ID!): Translation
|
|
translations(
|
|
workId: ID!
|
|
language: String
|
|
limit: Int
|
|
offset: Int
|
|
): [Translation!]!
|
|
|
|
# Author queries
|
|
author(id: ID!): Author
|
|
authors(
|
|
limit: Int
|
|
offset: Int
|
|
search: String
|
|
countryId: ID
|
|
): [Author!]!
|
|
|
|
# User queries
|
|
user(id: ID!): User
|
|
userByEmail(email: String!): User
|
|
userByUsername(username: String!): User
|
|
users(
|
|
limit: Int
|
|
offset: Int
|
|
role: UserRole
|
|
): [User!]!
|
|
me: User
|
|
userProfile(userId: ID!): UserProfile
|
|
|
|
# Collection queries
|
|
collection(id: ID!): Collection
|
|
collections(
|
|
userId: ID
|
|
limit: Int
|
|
offset: Int
|
|
): [Collection!]!
|
|
|
|
# Tag queries
|
|
tag(id: ID!): Tag
|
|
tags(limit: Int, offset: Int): [Tag!]!
|
|
|
|
# Category queries
|
|
category(id: ID!): Category
|
|
categories(limit: Int, offset: Int): [Category!]!
|
|
|
|
# Comment queries
|
|
comment(id: ID!): Comment
|
|
comments(
|
|
workId: ID
|
|
translationId: ID
|
|
userId: ID
|
|
limit: Int
|
|
offset: Int
|
|
): [Comment!]!
|
|
|
|
# Search
|
|
search(
|
|
query: String!
|
|
limit: Int
|
|
offset: Int
|
|
filters: SearchFilters
|
|
): SearchResults!
|
|
|
|
trendingWorks(timePeriod: String, limit: Int): [Work!]!
|
|
popularTranslations(workID: ID!, limit: Int): [Translation!]!
|
|
}
|
|
|
|
input SearchFilters {
|
|
languages: [String!]
|
|
categories: [ID!]
|
|
tags: [ID!]
|
|
authors: [ID!]
|
|
dateFrom: String
|
|
dateTo: String
|
|
}
|
|
|
|
type SearchResults {
|
|
works: [Work!]!
|
|
translations: [Translation!]!
|
|
authors: [Author!]!
|
|
total: Int!
|
|
}
|
|
|
|
# Mutations
|
|
type Mutation {
|
|
# Authentication
|
|
register(input: RegisterInput!): AuthPayload!
|
|
login(input: LoginInput!): AuthPayload!
|
|
|
|
# Work mutations
|
|
createWork(input: WorkInput!): Work!
|
|
updateWork(id: ID!, input: WorkInput!): Work!
|
|
deleteWork(id: ID!): Boolean!
|
|
|
|
# Translation mutations
|
|
createTranslation(input: TranslationInput!): Translation!
|
|
updateTranslation(id: ID!, input: TranslationInput!): Translation!
|
|
deleteTranslation(id: ID!): Boolean!
|
|
|
|
# Author mutations
|
|
createAuthor(input: AuthorInput!): Author!
|
|
updateAuthor(id: ID!, input: AuthorInput!): Author!
|
|
deleteAuthor(id: ID!): Boolean!
|
|
|
|
# User mutations
|
|
updateUser(id: ID!, input: UserInput!): User!
|
|
deleteUser(id: ID!): Boolean!
|
|
|
|
# Collection mutations
|
|
createCollection(input: CollectionInput!): Collection!
|
|
updateCollection(id: ID!, input: CollectionInput!): Collection!
|
|
deleteCollection(id: ID!): Boolean!
|
|
addWorkToCollection(collectionId: ID!, workId: ID!): Collection!
|
|
removeWorkFromCollection(collectionId: ID!, workId: ID!): Collection!
|
|
|
|
# Comment mutations
|
|
createComment(input: CommentInput!): Comment!
|
|
updateComment(id: ID!, input: CommentInput!): Comment!
|
|
deleteComment(id: ID!): Boolean!
|
|
|
|
# Like mutations
|
|
createLike(input: LikeInput!): Like!
|
|
deleteLike(id: ID!): Boolean!
|
|
|
|
# Bookmark mutations
|
|
createBookmark(input: BookmarkInput!): Bookmark!
|
|
deleteBookmark(id: ID!): Boolean!
|
|
|
|
# Contribution mutations
|
|
createContribution(input: ContributionInput!): Contribution!
|
|
updateContribution(id: ID!, input: ContributionInput!): Contribution!
|
|
deleteContribution(id: ID!): Boolean!
|
|
reviewContribution(id: ID!, status: ContributionStatus!, feedback: String): Contribution!
|
|
|
|
# Additional authentication mutations
|
|
logout: Boolean!
|
|
refreshToken: AuthPayload!
|
|
forgotPassword(email: String!): Boolean!
|
|
resetPassword(token: String!, newPassword: String!): Boolean!
|
|
verifyEmail(token: String!): Boolean!
|
|
resendVerificationEmail(email: String!): Boolean!
|
|
|
|
# User profile mutations
|
|
updateProfile(input: UserInput!): User!
|
|
changePassword(currentPassword: String!, newPassword: String!): Boolean!
|
|
}
|
|
|
|
# Input types
|
|
input LoginInput {
|
|
email: String!
|
|
password: String!
|
|
}
|
|
|
|
input RegisterInput {
|
|
username: String!
|
|
email: String!
|
|
password: String!
|
|
firstName: String!
|
|
lastName: String!
|
|
}
|
|
|
|
type AuthPayload {
|
|
token: String!
|
|
user: User!
|
|
}
|
|
|
|
input WorkInput {
|
|
name: String!
|
|
language: String!
|
|
content: String
|
|
authorIds: [ID!]
|
|
tagIds: [ID!]
|
|
categoryIds: [ID!]
|
|
}
|
|
|
|
input TranslationInput {
|
|
name: String!
|
|
language: String!
|
|
content: String
|
|
workId: ID!
|
|
}
|
|
|
|
input AuthorInput {
|
|
name: String!
|
|
language: String!
|
|
biography: String
|
|
birthDate: String
|
|
deathDate: String
|
|
countryId: ID
|
|
cityId: ID
|
|
placeId: ID
|
|
addressId: ID
|
|
}
|
|
|
|
input UserInput {
|
|
username: String
|
|
email: String
|
|
password: String
|
|
firstName: String
|
|
lastName: String
|
|
displayName: String
|
|
bio: String
|
|
avatarUrl: String
|
|
role: UserRole
|
|
verified: Boolean
|
|
active: Boolean
|
|
countryId: ID
|
|
cityId: ID
|
|
addressId: ID
|
|
}
|
|
|
|
input CollectionInput {
|
|
name: String!
|
|
description: String
|
|
workIds: [ID!]
|
|
}
|
|
|
|
input CommentInput {
|
|
text: String!
|
|
workId: ID
|
|
translationId: ID
|
|
lineNumber: Int
|
|
parentCommentId: ID
|
|
}
|
|
|
|
input LikeInput {
|
|
workId: ID
|
|
translationId: ID
|
|
commentId: ID
|
|
}
|
|
|
|
input BookmarkInput {
|
|
name: String
|
|
workId: ID!
|
|
}
|
|
|
|
input ContributionInput {
|
|
name: String!
|
|
workId: ID
|
|
translationId: ID
|
|
status: ContributionStatus
|
|
}
|