Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,64 @@ The API documentation can be found in the wiki in the [API docs](api.md).

The API is available at `/graphql` and the GraphiQL interface is available at `/graphiql`.

## Package Structure – `de.unistuttgart.iste.meitrex.gamification_service.service`

The business logic of the Gamification Service is organized in the package
`de.unistuttgart.iste.meitrex.gamification_service.service`.
To ensure a clear architecture and separation of concerns, this package is divided into several **subpackages**:

| Package | Purpose |
|-------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `de.unistuttgart.iste.meitrex.gamification_service.service.internal` | Contains **implementations of business logic** that are **not directly exposed via GraphQL**. These classes encapsulate internal application logic and are typically resolvers or event listeners. |
| `de.unistuttgart.iste.meitrex.gamification_service.service.functional` | Contains **functional, idempotent utility functions** that have no side effects. These classes and methods are usually stateless and can safely be reused throughout the application. |
| `de.unistuttgart.iste.meitrex.gamification_service.service.reactive` | Contains **event listeners** that extend `de.unistuttgart.iste.meitrex.gamification_service.events.internal.AbstractInternalListener`. These listeners react to **application events**, especially **external events that have been translated into internal events**. |

### Implementation Guidelines
- **`internal`**: Use this package for services or components that implement complex business rules or calculations. These components may be used by various GraphQL resolvers or event listeners, but they should not handle transport or DTO transformations.
- **`functional`**: Use this package for small, easily testable functions that perform calculations, formatting, or transformations without modifying the application state.
- **`reactive`**: Listeners in this package are triggered via Spring Application Events and handle reactions to internal event streams. Typically, these correspond to **external events** that have been converted into **internal events** by a separate adapter layer.

## Processing External Events

External events are processed in three steps to ensure durability:

1. **Reception & Mapping**
External events are received through Dapr pub/sub endpoints by listeners extending
`de.unistuttgart.iste.meitrex.gamification_service.dapr.AbstractExternalListener<T>`.
These map incoming `CloudEvent<T>` payloads to `PersistentEvent` objects and pass them to the `IEventPublicationService`.

2. **Persistence & Publication**
The `DefaultEventPublicationService` persists each `PersistentEvent` and publishes a corresponding `InternalEvent` after the surrounding transaction commits.
Duplicate events (based on sequence numbers) are ignored.

3. **Internal Event Handling**
Internal events (`de.unistuttgart.iste.meitrex.gamification_service.events.internal.InternalEvent`) are processed asynchronously by listeners extending
`de.unistuttgart.iste.meitrex.gamification_service.events.internal.AbstractInternalListener<U,V>`.
These listeners:
- Load the persistent event from the database,
- Track processing status and retry attempts,
- Execute business logic in `doProcess(U persistentEvent)`,
- Distinguish between transient and non-transient failures.

**Example Flow**:
`CloudEvent<T>` → `AbstractExternalListener` → `PersistentEvent` stored → `InternalEvent` published → `AbstractInternalListener` processes domain logic.

### Implementing a Custom Internal Listener

To react to new internal events, developers must:

1. **Create a `PersistentEvent` subclass** representing the data structure to be stored for the new external event.
2. **Extend `AbstractExternalListener<T>`** to receive and map the external event to the new `PersistentEvent`.
3. **Register the new event type** in `DefaultEventPublicationService` by adding a mapping from the new `PersistentEvent` class to a function that saves it and creates a corresponding `InternalEvent`.
4. **Create an `InternalEvent` subclass** that references the persisted event’s UUID.
5. **Implement a new listener** by extending `AbstractInternalListener<YourPersistentEvent, YourInternalEvent>` and overriding:
- `getName()` with a unique, stable identifier,
- `doProcess(...)` with the actual business logic to run when the event is processed.
6. Optionally throw `TransientEventListenerException` for retryable errors or `NonTransientEventListenerException` for permanent failures.

After these steps, the new internal listener will automatically react to published internal events of the specified type.


## Get started
A guide how to start development can be
found in the [wiki](https://meitrex.readthedocs.io/en/latest/dev-manuals/backend/get-started.html).
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.unistuttgart.iste.meitrex.gamification_service.events.internal;

import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import de.unistuttgart.iste.meitrex.gamification_service.events.persistent.PersistentEvent;
import de.unistuttgart.iste.meitrex.gamification_service.events.repository.IPersistentEventRepository;
import de.unistuttgart.iste.meitrex.gamification_service.events.repository.IPersistentEventStatusRepository;
Expand All @@ -10,6 +11,24 @@
import java.nio.charset.StandardCharsets;
import java.util.*;

/**
* Abstract base class for internal event listeners in the gamification service. This listener implements generic
* functionality for processing {@link PersistentEvent} and their corresponding {@link InternalEvent}s It handles:
* (1) fetching the associated persistent event from the repository, (2) tracking and updating processing status
* for retries and failures, (3) delegating the actual business logic to subclasses via {@link #doProcess(PersistentEvent)},
* (4) retry logic with configurable max attempt count. Any concrete listener is expected to be executed within a
* transactional context (e.g., managed by Spring), otherwise, event status updates will not be persisted.
*
* To implement a custom listener for a specific type of event, create a subclass overriding the following methods:
* (1) {@link #getName()} - must return a unique and stable name.
* (2) {@link #doProcess(U persistentEvent))} - contains the actual processing logic. In case of an unrecoverable error,
* an implementation should throw a {@link NonTransientEventListenerException}. Otherwise, {@link TransientEventListenerException}
* should be thrown. In the latter case, the base class logic takes care of updating the event status and retrying.
*
* @param <U> the type of the persistent event associated with the internal event.
* @param <V> the type of the internal event to be processed.
* @author Philiipp Kunz
*/
public abstract class AbstractInternalListener<U extends PersistentEvent, V extends InternalEvent> {


Expand Down Expand Up @@ -43,6 +62,13 @@ public final UUID getListenerUUID() {
return UUID.nameUUIDFromBytes(getName().getBytes(StandardCharsets.UTF_8));
}

@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logExecutionTime = false,
logExit = false
)
public void process(V internalEvent) {
assureIsValid(internalEvent);
process(fetchPersistentEvent(internalEvent));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
import org.springframework.context.*;


/**
* Each {@code InternalEvent} corresponds to a {@link de.unistuttgart.iste.meitrex.gamification_service.events.persistent.PersistentEvent}
* stored in the database and is represented as a Spring {@link org.springframework.context.ApplicationEvent}. These events
* can be published within the application context and are typically consumed by {@link AbstractInternalListener} implementations.
* Every {@code InternalEvent} is identified by a unique {@link java.util.UUID}, which references the corresponding
* {@link de.unistuttgart.iste.meitrex.gamification_service.events.persistent.PersistentEvent} instance.
*
* @author Philipp Kunz
*/
@Getter
public abstract class InternalEvent extends ApplicationEvent {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package de.unistuttgart.iste.meitrex.gamification_service.events.internal;

/**
* Exception indicating a non-recoverable error during the processing of an internal event. Throwing this exception
* signals hat the event processing has failed permanently and should not be retried by the surrounding infrastructure.
*
* @see AbstractInternalListener
*/
public class NonTransientEventListenerException extends InternalEventListenerException {

public NonTransientEventListenerException() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package de.unistuttgart.iste.meitrex.gamification_service.events.internal;

/**
* Exception indicating a recoverable error during the processing of an internal event. Throwing this exception
* signals hat the event processing has permanently and should be retried by the surrounding infrastructure.
*
* @see AbstractInternalListener
*/
public class TransientEventListenerException extends InternalEventListenerException {

public TransientEventListenerException() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
import java.util.List;
import java.util.UUID;

/**
* Base class for all persistently stored events within the gamification service. A PersistentEvent represents an
* externally received event that is stored permanently in the database. Each event has a unique UUID as its primary key,
* an optional message sequence number, and a timestamp indicating the time it was received. Each PersistentEvent can
* have multiple associated processing statuses, which are managed in the embedded PersistentEventStatus class. Such a
* status represents the processing state of an event for a specific listener. This allows the progress and outcome of
* event processing to be tracked per listener, including the current attempt count, the maximum number of allowed retries,
* and the timestamp of the last processing attempt. The class is declared abstract because concrete event types are
* defined through subclasses. These subclasses may contain additional domain-specific information required for further
* processing.
*
* @author Philipp Kunz
*/
@Getter
@Setter
@SuperBuilder
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.unistuttgart.iste.meitrex.gamification_service.events.publication;

import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import de.unistuttgart.iste.meitrex.gamification_service.events.PersistentMediaRecordWorkedOnEvent;
import de.unistuttgart.iste.meitrex.gamification_service.events.internal.*;
import de.unistuttgart.iste.meitrex.gamification_service.events.persistent.*;
Expand All @@ -17,6 +18,27 @@
import java.util.UUID;
import java.util.function.Function;

/**
* Default implementation of {@link IEventPublicationService} that persists incoming persistent events,
* ensures that duplicate events are not processed, and publishes corresponding internal events
* after a successful transaction commit.
*
* This service uses a type-to-handler map to delegate the persistence and conversion logic
* for different event types. The {@link org.springframework.transaction.support.TransactionSynchronizationManager}
* is used to ensure that publication happens only after the surrounding database transaction commits successfully.
*
* Duplicate detection is applied only to events that implement the {@link de.unistuttgart.iste.meitrex.gamification_service.events.persistent.ISequenced}
* interface. For these events, the message sequence number is checked against the database to prevent reprocessing
* of duplicates. All other events are always treated as new and published unconditionally.
*
* To support additional event types, implement a corresponding persistence and conversion method (e.g. {@code saveMyEventType}),
* and register it in the {@code handlerMap} inside the constructor, mapping the new persistent event class
* to the handler function. This allows the service to process and publish the new event type without modifying
* the core publishing logic.
*
* @author Philipp Kunz
*/

@Slf4j
@Component
class DefaultEventPublicationService implements IEventPublicationService {
Expand Down Expand Up @@ -109,6 +131,13 @@ public DefaultEventPublicationService(
}

@Override
@Loggable(
inLogLevel = Loggable.LogLevel.DEBUG,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logArgs = false,
logExecutionTime = false
)
public void saveCommitAndPublishIfNew(PersistentEvent persistentEvent) {
if(!this.handlerMap.containsKey(persistentEvent.getClass())) {
throw new IllegalArgumentException(ERR_MSG_UNSUPPORTED_EVENT_TYPE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,23 @@

import de.unistuttgart.iste.meitrex.gamification_service.events.persistent.PersistentEvent;

/**
* Defines the contract for publishing internal application events. Implementations are responsible for persisting
* events, ensuring that duplicate events are not processed, and publishing new events only after the surrounding
* transaction has been successfully committed.
*
* @author Philipp Kunz
*/
public interface IEventPublicationService {


/**
* Saves the given event if it has not been processed before and publishes it after the current transaction
* commits successfully as an instance of {@link de.unistuttgart.iste.meitrex.gamification_service.events.internal.InternalEvent}.
*
* @param persistentEvent the event to persist and publish (must not be {@code null})
* @throws IllegalArgumentException if the event type is unsupported or the event is invalid
*/
void saveCommitAndPublishIfNew(PersistentEvent persistentEvent);

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public DefaultKeycloakClient(

@Override
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
inLogLevel = Loggable.LogLevel.DEBUG,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logExecutionTime = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public DefaultUserConfiguration(IKeycloakClient keycloakClient) {

@Override
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
inLogLevel = Loggable.LogLevel.DEBUG,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logExecutionTime = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import java.util.*;

import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -16,6 +17,7 @@ public class LeaderboardMapper {

private final UserScoreMapper userScoreMapper;


public Leaderboard toDTO(LeaderboardEntity leaderboardEntity, int maxDepth) {
if(leaderboardEntity == null || maxDepth < 0) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package de.unistuttgart.iste.meitrex.gamification_service.persistence.mapper;


import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;
Expand All @@ -14,6 +15,7 @@
@RequiredArgsConstructor
public class UserMapper {


public User toDTO(UserEntity userEntity, int maxDepth) {
if(userEntity == null || maxDepth < 0) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.unistuttgart.iste.meitrex.gamification_service.persistence.mapper;

import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.*;

import de.unistuttgart.iste.meitrex.course_service.client.CourseServiceClient;
import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import de.unistuttgart.iste.meitrex.gamification_service.service.internal.achievements.ICourseAchievementMapper;
import de.unistuttgart.iste.meitrex.gamification_service.service.internal.ICourseCreator;
import de.unistuttgart.iste.meitrex.gamification_service.time.IPeriodCalculator;
Expand Down Expand Up @@ -67,6 +68,12 @@ public DefaultCourseService(
}

@Override
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logExecutionTime = false
)
public CourseEntity fetchOrCreate(UUID courseId) {
return this.courseRepository
.findById(courseId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.*;
import java.util.*;

import de.unistuttgart.iste.meitrex.gamification_service.aspects.logging.Loggable;
import jakarta.transaction.*;

import org.springframework.stereotype.*;
Expand Down Expand Up @@ -100,6 +101,12 @@ public DefaultLeaderboardService(
// Interface implementation

@Override
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
exitLogLevel = Loggable.LogLevel.DEBUG,
exceptionLogLevel = Loggable.LogLevel.WARN,
logExecutionTime = false
)
public List<Leaderboard> find(UUID courseID, LocalDate date, Period period) {
return this.leaderboardRepository.findByCourseIdAndDateAfterAndPeriod(courseID, date, period)
.stream()
Expand All @@ -112,12 +119,22 @@ public List<Leaderboard> find(UUID courseID, LocalDate date, Period period) {

@Transactional
@Scheduled(cron = "0 0 0 * * MON")
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
exitLogLevel = Loggable.LogLevel.INFO,
exceptionLogLevel = Loggable.LogLevel.WARN
)
public void runWeeklyLeaderboardUpdate() {
this.updateAllCourseLeaderboards(Period.WEEKLY);
}

@Transactional
@Scheduled(cron = "0 0 0 1 * *")
@Loggable(
inLogLevel = Loggable.LogLevel.INFO,
exitLogLevel = Loggable.LogLevel.INFO,
exceptionLogLevel = Loggable.LogLevel.WARN
)
public void runMonthlyLeaderboardUpdate() {
this.updateAllCourseLeaderboards(Period.MONTHLY);
}
Expand All @@ -135,6 +152,7 @@ public List<Leaderboard> findAllWeekly() {
@Autowired
private LeaderboardMapper mapper;


public List<Leaderboard> findAll(Period period) {
List<LeaderboardEntity> list = new ArrayList<>();
this.courseRepository
Expand Down
Loading
Loading