diff --git a/README.md b/README.md
index 7b6807c..51e0da2 100644
--- a/README.md
+++ b/README.md
@@ -1,164 +1,42 @@
-# template-microservice
-This serves as a template for the microservices
+# Notification Service
-## Package structure
+The Notification Service is a component designed to fulfill the following functions within the platform:
-This package structure is based on multiple sources of best practices in Spring Boot, using roughly the "Package by layer" approach.
-- *root*
- - *config*
- - *controller*
- - *dapr*
- - *dto*
- - *exception*
- - *persistence*
- - *entity*
- - *repository*
- - *mapper*
- - *service*
- - *util* (optional, if needed)
- - *validation*
+1. **Event ingestion & routing:** Consumes cross-service notification events (e.g., Media, Content) and routes them to target recipients (explicit user list or course-wide broadcast).
+2. **Persistence & recipient tracking:** Persists notifications and creates recipient rows per target user with status management (`UNREAD`, `READ`, `DO_NOT_NOTIFY`).
+3. **User preference filtering:** Respects per-user notification settings to mute specific categories; if the settings service is unavailable, it fails open to avoid missed notifications.
+4. **Delivery interfaces:** Exposes unread counts, listing, mark-as-read (single/all), delete (single/all), and provides a live “notification added” stream.
-Detailed description of the packages:
+## Notification Service
-### Root package
+## Environment variables
-This should be named after the microservice itself. This is the root package for the microservice. It contains the `Application.java` file (or of similar name), which is the entry point for the microservice. Usually, this is the only class in this package.
+### Relevant for deployment
-### Config package
-This package should contain any classes that are used to configure the application. This includes [Sprint Boot configuration classes](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/annotation/Configuration.html) but might also contain anything else related to configuration the microservice.
-The classes that are in this package should not be deleted in the actual microservice as they provide useful functionality.
+| Name | Description | Default Value in Prod Environment |
+|------------------------------|-------------------------------------|---------------------------------------------------------------------------------------|
+| `spring.datasource.url` | PostgreSQL database URL | `jdbc:postgresql://notification-service-db-postgresql:5432/notification-service` |
+| `spring.datasource.username` | Database username | `postgres` |
+| `spring.datasource.password` | Database password | `*secret*` |
+| `DAPR_HTTP_PORT` | Dapr HTTP Port | `3500` |
+| `DAPR_GRPC_PORT` | Dapr gRPC Port | `50001` |
-### Controller package
+### Other properties
-**Location:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/controller**
+| Name | Description | Example / Default |
+|------------------------------------|---------------------------------------------------|--------------------------------------|
+| `services.course.base-url` | Course Service base URL (member lookup) | `http://course-service:8080` |
+| `services.user-settings.base-url` | User Settings Service base URL (preference checks)| `http://user-settings-service:8080` |
+| `notifications.cleanup.enabled` | Remove orphan notifications with no recipients | `true` |
+| `notifications.stream.buffer-size` | In-memory buffer size for the live publisher | `256` |
-This package contains the GraphQL controllers (and other types of controllers if needed). The GraphQL controllers are annotated with the `@Controller` annotation. Controllers contain no business logic, but only delegate the requests to the service layer. They handle the "technical stuff" of the request.
+## GraphQL API
-In some services, there is also a class called SubscriptionController which handles all dapr event subscriptions.
+The API is documented in the [`api.md` file](api.md).
-More information can be found in
-the [Controller package](src/main/java/de/unistuttgart/iste/meitrex/template/controller/package-info.java).
+It can be accessed at `/graphql` and explored via the GraphiQL Playground at `/graphiql`.
-### Dapr package
+## Get started
-**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/dapr
-
-This package should contain all classes that are used to communicate with Dapr, e.g. using pub sub.
-
-### DTO package
-
-**This package will not be located in the src/main/java folder, but in the build/generated folder.**
-
-This package contains the generated DTOs (data transfer objects) from the GraphQL schema. The DTOs are generated when building the project with gradle.
-
-If not necessary, no other files should be added manually to this package.
-
-#### Why both DTOs and Entities?
-
-The DTOs are used to transfer data between the GraphQL controller and the service layer. The entities are used to persist data in the database. This is done to separate the data transfer from the data persistence. This is a common approach in Spring Boot applications as it can happen that we want to store more data in the database than we want to transfer to the client or vice versa.
-
-### Exception package
-
-**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/exception
-
-This package is used for exception handling. Note that with GraphQL, the exceptions are not thrown directly, but are wrapped in a `GraphQLException`, which is different that from the usual Spring Boot approach.
-
-More information can be found in
-the [Exception package](src/main/java/de/unistuttgart/iste/meitrex/template/exception/package-info.java).
-
-### Persistence package
-
-**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/persistence
-
-This package contains all classes that are used to persist data in the database. This includes the entities, the mapping
-logic between entities and DTOs, as well as the repositories.
-
-This package handles the calls to the database and defines the database entities. It is structured into three sub-packages:
-
-#### 1. entity
-This package contains the database entities.
-
-#### 2. repository
-This package contains the interfaces to the database, also known as Data Access Objects (DAOs), used to perform various database operations. Note that these interfaces may sometimes be empty, especially when the default methods provided by the Spring Framework are sufficient for the required operations.
-
-#### 3. mapper
-The 'mapper' package is responsible for the mapping logic between the database entities and the data types defined in the GraphQL schema. Specifically, it maps the database entity classes to the corresponding classes generated from the GraphQL schema.
-
-This structure helps organize the database-related components of the project, making it easier to manage and maintain.
-
-More information can be found in
-the [Entity package](src/main/java/de/unistuttgart/iste/meitrex/template/persistence/entity/package-info.java) and
-the [Repository package](src/main/java/de/unistuttgart/iste/meitrex/template/persistence/repository/package-info.java).
-
-### Service package
-
-**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/service
-
-This package contains all classes that are used to handle the business logic of the microservice. Services are annotated with the `@Service` annotation. Services contain only business logic and delegate the data access to the persistence layer (repositories).
-
-More information can be found in
-the [Service package](src/main/java/de/unistuttgart/iste/meitrex/template/service/package-info.java).
-
-### Validation package
-
-**Location**:src/main/java/de/unistuttgart/iste/meitrex/{service_name}/validation
-
-This package should contain the *class-level* validation logic, i.e. the validation logic that is not directly related to a specific field, e.g. validation if an end date is after a start date.
-
-Field-level validation logic should not be placed in this package, but in the graphql schema, via directives.
-If these directives are not sufficient, the validation logic can also be placed in this package.
-
-## Getting Started
-
-### Todos
-
-Follow the guide in the wiki: https://github.com/MEITREX/wiki/blob/main/dev-manuals/backend/new-service.md
-
-Addtionally, after cloning the repository, you need to do the following steps:
-- [ ] Setup the gradle files correctly. This means
- - [ ] Change the project name in the `settings.gradle` file
- - [ ] Change the sonar project key in the `build.gradle` file (should be MEITREX_repository_name)
- - [ ] Add/Remove dependencies in the `build.gradle` file
-- [ ] Rename the package in the `src/main/java` folder to a more suitable name (should be the service name)
-- [ ] Remove the package-info.java files in the `src/main/java` folder, if the file is present (or update with the microservice specific information)
-- [ ] Update the application.properties file in the `src/main/resources` folder (check the TODOS in the file)
-- [ ] Change the ports and name of the database in the docker-compose.yml (see wiki on how to)
-- [ ] Define the GraphQL schema in the `src/main/resources/schema.graphqls` file
-
-
-
-After creating a new service you need to do the following:
-- [ ] Add the repository to sonarcloud, follow the instructions for extra configuration, unselect automatic analysis and choose github actions, only the first step needs to be completed
-- [ ] Add SONAR_TOKEN to the service repository secrets on Github (this requires you to have admin permissions on sonarcloud)
-
-### Pull new changes from this template
-
-If this template changes and you want to pull the changes to the actual microservice, you can run the following commands:
-```bash
-git remote add template https://github.com/MEITREX/template_microservice # only necessary once
-git fetch --all
-git checkout [branch] # replace [branch] with the branch name you want the changes to be merged into (preferably not main)
-git merge template/main --allow-unrelated-histories
-# you will probably need to commit afterwars
-```
-
-### Guides
-The following guides illustrate how to use some features concretely:
-
-* [Building a GraphQL service](https://spring.io/guides/gs/graphql-server/)
-* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
-* [Validation with GraphQL directives](https://github.com/graphql-java/graphql-java-extended-validation/blob/master/README.md)
-* [Error handling](https://www.baeldung.com/spring-graphql-error-handling)
-
-### Reference Documentation
-For further reference, please consider the following sections:
-
-* [Official Gradle documentation](https://docs.gradle.org)
-* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/3.0.6/gradle-plugin/reference/html/)
-* [Spring Configuration Processor](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#appendix.configuration-metadata.annotation-processor)
-* [Spring Boot DevTools](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#using.devtools)
-* [Spring for GraphQL](https://docs.spring.io/spring-boot/docs/3.0.6/reference/html/web.html#web.graphql)
-* [Spring Data JPA](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#data.sql.jpa-and-spring-data)
-* [Validation](https://docs.spring.io/spring-boot/docs/3.0.6/reference/htmlsingle/#io.validation)
-* [Generating Sonarqube Token](https://docs.sonarqube.org/latest/user-guide/user-account/generating-and-using-tokens/)
-* [Adding secrets on Github](https://docs.github.com/en/actions/security-guides/encrypted-secrets)
+A guide how to start development can be found in the
+[wiki](https://meitrex.readthedocs.io/en/latest/dev-manuals/backend/get-started.html).
diff --git a/api.md b/api.md
new file mode 100644
index 0000000..f64c57d
--- /dev/null
+++ b/api.md
@@ -0,0 +1,475 @@
+# Notification Service API
+
+
+ Table of Contents
+
+ * [Query](#query)
+ * [Mutation](#mutation)
+ * [Subscription](#subscription)
+ * [Objects](#objects)
+ * [NotificationData](#notificationdata)
+ * [PaginationInfo](#paginationinfo)
+ * [Inputs](#inputs)
+ * [DateTimeFilter](#datetimefilter)
+ * [IntFilter](#intfilter)
+ * [Pagination](#pagination)
+ * [StringFilter](#stringfilter)
+ * [Enums](#enums)
+ * [SortDirection](#sortdirection)
+ * [Scalars](#scalars)
+ * [Boolean](#boolean)
+ * [Date](#date)
+ * [DateTime](#datetime)
+ * [Int](#int)
+ * [LocalTime](#localtime)
+ * [String](#string)
+ * [Time](#time)
+ * [UUID](#uuid)
+ * [Url](#url)
+
+
+
+## Query
+
+
+
+| Field |
+Argument |
+Type |
+Description |
+
+
+
+
+| notifications |
+[NotificationData!]! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+| countUnread |
+Int! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+
+
+## Mutation
+
+
+
+| Field |
+Argument |
+Type |
+Description |
+
+
+
+
+| markAllRead |
+Int! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+| markOneRead |
+Int! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+| notificationId |
+UUID! |
+ |
+
+
+| deleteOneNotification |
+Int! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+| notificationId |
+UUID! |
+ |
+
+
+| deleteAllNotifications |
+Int! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+
+
+## Subscription
+
+
+
+| Field |
+Argument |
+Type |
+Description |
+
+
+
+
+| notificationAdded |
+NotificationData! |
+ |
+
+
+| userId |
+UUID! |
+ |
+
+
+
+
+## Objects
+
+### NotificationData
+
+
+
+
+| Field |
+Argument |
+Type |
+Description |
+
+
+
+
+| id |
+UUID! |
+ |
+
+
+| title |
+String! |
+ |
+
+
+| description |
+String! |
+ |
+
+
+| href |
+String! |
+ |
+
+
+| createdAt |
+DateTime! |
+ |
+
+
+| read |
+Boolean! |
+ |
+
+
+
+
+### PaginationInfo
+
+Return type for information about paginated results.
+
+
+
+
+| Field |
+Argument |
+Type |
+Description |
+
+
+
+
+ |
+Int! |
+
+
+The current page number.
+
+ |
+
+
+ |
+Int! |
+
+
+The number of elements per page.
+
+ |
+
+
+ |
+Int! |
+
+
+The total number of elements across all pages.
+
+ |
+
+
+ |
+Int! |
+
+
+The total number of pages.
+
+ |
+
+
+ |
+Boolean! |
+
+
+Whether there is a next page.
+
+ |
+
+
+
+
+## Inputs
+
+### DateTimeFilter
+
+Filter for date values.
+If multiple filters are specified, they are combined with AND.
+
+
+
+
+| Field |
+Type |
+Description |
+
+
+
+
+| after |
+DateTime |
+
+
+If specified, filters for dates after the specified value.
+
+ |
+
+
+| before |
+DateTime |
+
+
+If specified, filters for dates before the specified value.
+
+ |
+
+
+
+
+### IntFilter
+
+Filter for integer values.
+If multiple filters are specified, they are combined with AND.
+
+
+
+
+| Field |
+Type |
+Description |
+
+
+
+
+| equals |
+Int |
+
+
+An integer value to match exactly.
+
+ |
+
+
+| greaterThan |
+Int |
+
+
+If specified, filters for values greater than to the specified value.
+
+ |
+
+
+| lessThan |
+Int |
+
+
+If specified, filters for values less than to the specified value.
+
+ |
+
+
+
+
+### Pagination
+
+Specifies the page size and page number for paginated results.
+
+
+
+
+| Field |
+Type |
+Description |
+
+
+
+
+ |
+Int! |
+
+
+The page number, starting at 0.
+If not specified, the default value is 0.
+For values greater than 0, the page size must be specified.
+If this value is larger than the number of pages, an empty page is returned.
+
+ |
+
+
+ |
+Int! |
+
+
+The number of elements per page.
+
+ |
+
+
+
+
+### StringFilter
+
+Filter for string values.
+If multiple filters are specified, they are combined with AND.
+
+
+
+
+| Field |
+Type |
+Description |
+
+
+
+
+| equals |
+String |
+
+
+A string value to match exactly.
+
+ |
+
+
+| contains |
+String |
+
+
+A string value that must be contained in the field that is being filtered.
+
+ |
+
+
+| ignoreCase |
+Boolean! |
+
+
+If true, the filter is case-insensitive.
+
+ |
+
+
+
+
+## Enums
+
+### SortDirection
+
+Specifies the sort direction, either ascending or descending.
+
+
+
+
+| Value |
+Description |
+
+
+
+
+| ASC |
+ |
+
+
+| DESC |
+ |
+
+
+
+
+## Scalars
+
+### Boolean
+
+The `Boolean` scalar type represents `true` or `false`.
+
+### Date
+
+### DateTime
+
+### Int
+
+The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
+
+### LocalTime
+
+### String
+
+The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
+
+### Time
+
+### UUID
+
+### Url
+
diff --git a/build.gradle b/build.gradle
index 7144f4a..328ab61 100644
--- a/build.gradle
+++ b/build.gradle
@@ -108,22 +108,28 @@ repositories {
}
dependencies {
- implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.0.2'
+ implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.9'
+ implementation 'de.unistuttgart.iste.meitrex:course_service'
+ implementation 'de.unistuttgart.iste.meitrex:user_service'
+
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
- implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.modelmapper:modelmapper:3.+'
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'com.graphql-java:graphql-java-extended-scalars:22.0'
implementation 'com.graphql-java:graphql-java-extended-validation:22.0'
+ implementation 'io.dapr:dapr-sdk:1.9.0' // Dapr's core SDK with all features, except Actors.
+ implementation 'io.dapr:dapr-sdk-springboot:1.9.0' // Dapr's SDK integration with SpringBoot
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
annotationProcessor 'org.projectlombok:lombok'
- testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.0.2'
+ testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.9'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework:spring-webflux'
testImplementation 'org.springframework.graphql:spring-graphql-test'
diff --git a/docker-compose.yml b/docker-compose.yml
index 153fb7b..4667c48 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,8 +1,9 @@
version: "3"
services:
- database-notification:
+ database:
image: pgvector/pgvector:pg16
+ command: -c 'max_connections=500'
restart: unless-stopped
expose:
- 5432
@@ -14,7 +15,7 @@ services:
environment:
- POSTGRES_USER=root
- POSTGRES_PASSWORD=root
- - POSTGRES_CREATE_DB_GAMIFICATION_SERVICE=notification_service
+ - POSTGRES_CREATE_DB_NOTIFICATION_SERVICE=notification_service
app-notification:
build:
context: ./../notification_service # necessary because otherwise docker-compose overrides the context path when merging multiple docker-compose.yml files
@@ -28,7 +29,7 @@ services:
- "3100:3100"
- "3101:3101"
depends_on:
- - database-notification
+ - database
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/notification_service
SPRING_DATASOURCE_USERNAME: root
@@ -53,10 +54,9 @@ services:
expose:
- "6379"
volumes:
- notificationdata:
dbdata:
testdata:
networks:
default:
name: dapr-network
- external: true
+ external: true
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..48521e4
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,116 @@
+{
+ "name": "notification_service",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "graphql-markdown": "^7.3.0"
+ }
+ },
+ "node_modules/deep-diff": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
+ "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
+ "license": "MIT"
+ },
+ "node_modules/graphql": {
+ "version": "16.11.0",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
+ "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/graphql-markdown": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/graphql-markdown/-/graphql-markdown-7.3.0.tgz",
+ "integrity": "sha512-xTgP+W156DD3k55KpwvNUtV7RTJmGKQhvCzseUXSnJf7KrZozhyyxhq4bv1IiOJgGeRjt5zwHBbEcAEEWCacrg==",
+ "license": "MIT",
+ "dependencies": {
+ "deep-diff": "^1.0.2",
+ "lodash.isplainobject": "^4.0.6",
+ "minimist": "^1.2.6",
+ "node-fetch": "^2.0.0",
+ "resolve-from": "^5.0.0"
+ },
+ "bin": {
+ "graphql-markdown": "src/index.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.2 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..421e172
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "graphql-markdown": "^7.3.0"
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index 6268e2a..c0461ed 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -7,4 +7,10 @@ sourceControl {
gitRepository(uri('https://github.com/MEITREX/common_test')) {
producesModule('de.unistuttgart.iste.meitrex:meitrex-common-test')
}
+ gitRepository(uri('https://github.com/MEITREX/course_service')) {
+ producesModule('de.unistuttgart.iste.meitrex:course_service')
+ }
+ gitRepository(uri('https://github.com/MEITREX/user_service')) {
+ producesModule('de.unistuttgart.iste.meitrex:user_service')
+ }
}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/DownstreamClientsConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/DownstreamClientsConfiguration.java
new file mode 100644
index 0000000..143a201
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/DownstreamClientsConfiguration.java
@@ -0,0 +1,43 @@
+package de.unistuttgart.iste.meitrex.notification_service.config;
+
+import de.unistuttgart.iste.meitrex.course_service.client.CourseServiceClient;
+import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.graphql.client.GraphQlClient;
+import org.springframework.graphql.client.HttpGraphQlClient;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * Configures downstream GraphQL clients used by the notification-service.
+ *
+ * URLs are provided via application.properties or environment variables:
+ *
+ * - course_service.url = http://localhost:2001/graphql
+ * - user_service.url = http://localhost:5001/graphql
+ *
+ */
+@Configuration
+public class DownstreamClientsConfiguration {
+
+ @Bean
+ public CourseServiceClient courseServiceClient(
+ @Value("${course_service.url}") final String courseServiceUrl) {
+ final WebClient webClient = WebClient.builder()
+ .baseUrl(courseServiceUrl)
+ .build();
+ final GraphQlClient gql = HttpGraphQlClient.builder(webClient).build();
+ return new CourseServiceClient(gql);
+ }
+
+ @Bean
+ public SettingsServiceClient settingsServiceClient(
+ @Value("${user_service.url}") final String userServiceUrl) {
+ final WebClient webClient = WebClient.builder()
+ .baseUrl(userServiceUrl)
+ .build();
+ final GraphQlClient gql = HttpGraphQlClient.builder(webClient).build();
+ return new SettingsServiceClient(gql);
+ }
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/GraphQlScalarsAndValidationConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/GraphQlScalarsAndValidationConfiguration.java
deleted file mode 100644
index f834a32..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/GraphQlScalarsAndValidationConfiguration.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package de.unistuttgart.iste.meitrex.notification_service.config;
-
-import graphql.scalars.ExtendedScalars;
-import graphql.validation.rules.OnValidationErrorStrategy;
-import graphql.validation.rules.ValidationRules;
-import graphql.validation.schemawiring.ValidationSchemaWiring;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.graphql.execution.RuntimeWiringConfigurer;
-
-/**
- * This class sets up the validation rules for the GraphQL schema and the scalar types.
- */
-@Configuration
-public class GraphQlScalarsAndValidationConfiguration {
-
- @Bean
- public RuntimeWiringConfigurer runtimeWiringConfigurer() {
-
- ValidationRules validationRules = ValidationRules.newValidationRules()
- .onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL)
- .build();
-
- ValidationSchemaWiring schemaWiring = new ValidationSchemaWiring(validationRules);
-
- return wiringBuilder -> wiringBuilder
- .directiveWiring(schemaWiring)
- .scalar(ExtendedScalars.DateTime)
- .scalar(ExtendedScalars.Date)
- .scalar(ExtendedScalars.Time)
- .scalar(ExtendedScalars.LocalTime)
- .scalar(ExtendedScalars.UUID)
- .scalar(ExtendedScalars.Url);
- }
-}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/RequestHeaderUserInterceptor.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/RequestHeaderUserInterceptor.java
new file mode 100644
index 0000000..aa4fcfd
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/RequestHeaderUserInterceptor.java
@@ -0,0 +1,24 @@
+package de.unistuttgart.iste.meitrex.notification_service.config;
+
+import de.unistuttgart.iste.meitrex.common.user_handling.RequestHeaderUserProcessor;
+import lombok.SneakyThrows;
+import org.jetbrains.annotations.NotNull;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.graphql.server.WebGraphQlInterceptor;
+import org.springframework.graphql.server.WebGraphQlRequest;
+import org.springframework.graphql.server.WebGraphQlResponse;
+import reactor.core.publisher.Mono;
+
+/**
+ * This class is used to add data from the request headers to the GraphQL context.
+ */
+@Configuration
+public class RequestHeaderUserInterceptor implements WebGraphQlInterceptor {
+ @NotNull
+ @Override
+ @SneakyThrows
+ public Mono intercept(@NotNull WebGraphQlRequest request, @NotNull Chain chain) {
+ RequestHeaderUserProcessor.process(request);
+ return chain.next(request);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/package-info.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/package-info.java
deleted file mode 100644
index 964846a..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/config/package-info.java
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * This package should contain any classes that are used to configure the application.
- */
-package de.unistuttgart.iste.meitrex.notification_service.config;
\ No newline at end of file
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationController.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationController.java
index 3f9f304..c575fbc 100644
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationController.java
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationController.java
@@ -1,25 +1,107 @@
package de.unistuttgart.iste.meitrex.notification_service.controller;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import org.springframework.graphql.data.method.annotation.ContextValue;
-import de.unistuttgart.iste.meitrex.generated.dto.Template;
-import de.unistuttgart.iste.meitrex.notification_service.service.TemplateService;
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.notification_service.service.NotificationService;
import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
+import org.reactivestreams.Publisher;
+import org.springframework.graphql.data.method.annotation.Argument;
+import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
+import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import org.springframework.stereotype.Controller;
import java.util.List;
+import java.util.UUID;
-@Slf4j
+/**
+ * GraphQL controller exposing queries, mutations and subscriptions for notifications.
+ */
@Controller
@RequiredArgsConstructor
public class NotificationController {
- private final TemplateService notificationService;
+ private final NotificationService notificationService;
- @QueryMapping
- public List notification_services() {
- log.info("Request for all notification_services");
+ private void requireSelf(LoggedInUser currentUser, UUID userId) {
+ if (currentUser == null || currentUser.getId() == null || !currentUser.getId().equals(userId)) {
+ throw new IllegalArgumentException("Forbidden");
+ }
+ }
+
+ /**
+ * Returns all notifications for a user excluding DO_NOT_NOTIFY.
+ *
+ * @param userId user id
+ * @return list of NotificationData
+ */
+ @QueryMapping(name = "notifications")
+ public List getNotifications(@Argument final UUID userId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.getNotificationsForUser(userId);
+ }
+
+ /**
+ * Returns unread count for a user.
+ *
+ * @param userId user id
+ * @return unread count
+ */
+ @QueryMapping(name = "countUnread")
+ public int countUnread(@Argument final UUID userId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.countUnread(userId);
+ }
+
+ /**
+ * Marks all unread notifications as read for a user.
+ *
+ * @param userId user id
+ * @return affected rows
+ */
+ @MutationMapping
+ public int markAllRead(@Argument final UUID userId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.markAllRead(userId);
+ }
+
+ /**
+ * Marks a single notification as read for a user.
+ *
+ * @param userId user id
+ * @param notificationId notification id
+ * @return 0 or 1
+ */
+ @MutationMapping
+ public int markOneRead(@Argument final UUID userId,
+ @Argument final UUID notificationId,
+ @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.markOneRead(userId, notificationId);
+ }
+
+ /**
+ * Subscribes to newly added notifications for a user.
+ *
+ * @param userId user id
+ * @return publisher emitting NotificationData
+ */
+ @SubscriptionMapping
+ public Publisher notificationAdded(@Argument final UUID userId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.notificationAddedStream(userId);
+ }
+
+ @MutationMapping
+ public int deleteAllNotifications(@Argument UUID userId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.deleteAll(userId);
+ }
- return notificationService.getAllTemplates();
+ @MutationMapping
+ public int deleteOneNotification(@Argument UUID userId, @Argument UUID notificationId, @ContextValue final LoggedInUser currentUser) {
+ requireSelf(currentUser, userId);
+ return notificationService.deleteOne(userId, notificationId);
}
}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionController.java
new file mode 100644
index 0000000..3941302
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionController.java
@@ -0,0 +1,34 @@
+package de.unistuttgart.iste.meitrex.notification_service.controller;
+
+
+import de.unistuttgart.iste.meitrex.common.event.NotificationEvent;
+import de.unistuttgart.iste.meitrex.notification_service.service.NotificationService;
+import io.dapr.Topic;
+import io.dapr.client.domain.CloudEvent;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+import java.util.UUID;
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class SubscriptionController {
+ private final NotificationService notificationService;
+
+ @Topic(name = "notification-event", pubsubName = "meitrex")
+ @PostMapping(path = "/notification-event-pubsub")
+ public Mono handleNotificationEvent(@RequestBody CloudEvent cloudEvent,
+ @RequestHeader Map headers) {
+ return Mono.fromRunnable(() -> {
+ NotificationEvent event = cloudEvent.getData();
+ log.info("Received notification-event: {}", event);
+ notificationService.handleNotificationEvent(event);
+ });
+ }
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationEntity.java
new file mode 100644
index 0000000..b4c0fec
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationEntity.java
@@ -0,0 +1,32 @@
+package de.unistuttgart.iste.meitrex.notification_service.persistence.entity;
+
+import de.unistuttgart.iste.meitrex.common.persistence.IWithId;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Entity(name = "Notification")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class NotificationEntity implements IWithId {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(nullable = false)
+ private String title;
+
+ @Column(nullable = false, length = 1000)
+ private String description;
+
+ @Column(nullable = false)
+ private String href;
+
+ @Column(nullable = false)
+ private OffsetDateTime createdAt;
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationRecipientEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationRecipientEntity.java
new file mode 100644
index 0000000..209ed04
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/NotificationRecipientEntity.java
@@ -0,0 +1,33 @@
+package de.unistuttgart.iste.meitrex.notification_service.persistence.entity;
+
+import jakarta.persistence.*;
+import lombok.*;
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "notification_recipient",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"notification_id","user_id"}))
+@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
+public class NotificationRecipientEntity {
+
+ public enum RecipientStatus { UNREAD, READ, DO_NOT_NOTIFY }
+
+ @Id
+ @GeneratedValue
+ private UUID id;
+
+ @Column(name = "user_id", nullable = false)
+ private UUID userId;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "notification_id", nullable = false)
+ private NotificationEntity notification;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private RecipientStatus status;
+
+ @Column(name = "read_at")
+ private OffsetDateTime readAt;
+}
\ No newline at end of file
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/TemplateEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/TemplateEntity.java
deleted file mode 100644
index 269799d..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/entity/TemplateEntity.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.unistuttgart.iste.meitrex.notification_service.persistence.entity;
-
-import jakarta.persistence.*;
-import lombok.*;
-
-@Entity(name = "Template")
-@Getter
-@Setter
-@ToString
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class TemplateEntity {
-
- @Id
- @GeneratedValue(strategy = GenerationType.AUTO)
- private long id;
-
- @Column(nullable = false, length = 255)
- private String name;
-
-}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapper.java
new file mode 100644
index 0000000..a91db94
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapper.java
@@ -0,0 +1,31 @@
+package de.unistuttgart.iste.meitrex.notification_service.persistence.mapper;
+
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import org.springframework.stereotype.Component;
+
+/**
+ * Maps persistence entities to GraphQL DTOs.
+ */
+@Component
+public class NotificationMapper {
+
+ /**
+ * Maps NotificationEntity to NotificationData. The 'read' flag is filled by service layer.
+ *
+ * @param entity notification entity
+ * @return NotificationData dto
+ */
+ public NotificationData entityToDto(final NotificationEntity entity) {
+ if (entity == null) return null;
+
+ final NotificationData dto = new NotificationData();
+ dto.setId(entity.getId());
+ dto.setTitle(entity.getTitle());
+ dto.setDescription(entity.getDescription());
+ dto.setHref(entity.getHref());
+ dto.setCreatedAt(entity.getCreatedAt());
+ // dto.setRead(...) is set by caller (depends on recipient status)
+ return dto;
+ }
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/TemplateMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/TemplateMapper.java
deleted file mode 100644
index 7e7a8cd..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/TemplateMapper.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package de.unistuttgart.iste.meitrex.notification_service.persistence.mapper;
-
-import de.unistuttgart.iste.meitrex.generated.dto.Template;
-import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.TemplateEntity;
-import lombok.RequiredArgsConstructor;
-import org.modelmapper.ModelMapper;
-import org.springframework.stereotype.Component;
-
-@Component
-@RequiredArgsConstructor
-public class TemplateMapper {
-
- private final ModelMapper modelMapper;
-
- public Template entityToDto(TemplateEntity notification_serviceEntity) {
- // add specific mapping here if needed
- return modelMapper.map(notification_serviceEntity, Template.class);
- }
-
- public TemplateEntity dtoToEntity(Template notification_service) {
- // add specific mapping here if needed
- return modelMapper.map(notification_service, TemplateEntity.class);
- }
-}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRecipientRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRecipientRepository.java
new file mode 100644
index 0000000..4159f2d
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRecipientRepository.java
@@ -0,0 +1,79 @@
+// NotificationRecipientRepository.java
+package de.unistuttgart.iste.meitrex.notification_service.persistence.repository;
+
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import org.springframework.data.jpa.repository.*;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.*;
+
+@Repository
+public interface NotificationRecipientRepository extends JpaRepository {
+
+ @Query("""
+ SELECT r FROM NotificationRecipientEntity r
+ JOIN FETCH r.notification n
+ WHERE r.userId = :userId AND r.status <> :excluded
+ ORDER BY n.createdAt DESC
+ """)
+ List findAllByUserIdAndStatusNotOrderByCreatedAtDesc(
+ @Param("userId") UUID userId,
+ @Param("excluded") RecipientStatus excluded
+ );
+
+ @Query("""
+ SELECT COUNT(r) FROM NotificationRecipientEntity r
+ WHERE r.userId = :userId AND r.status = :status
+ """)
+ int countByUserIdAndStatus(@Param("userId") UUID userId, @Param("status") RecipientStatus status);
+
+ /** Keep for unread badge on the frontend. */
+ default int countUnread(UUID userId) {
+ return countByUserIdAndStatus(userId, RecipientStatus.UNREAD);
+ }
+
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ UPDATE NotificationRecipientEntity r
+ SET r.status = :newStatus, r.readAt = CURRENT_TIMESTAMP
+ WHERE r.userId = :userId AND r.status = :oldStatus
+ """)
+ int updateStatusForUser(
+ @Param("userId") UUID userId,
+ @Param("oldStatus") RecipientStatus oldStatus,
+ @Param("newStatus") RecipientStatus newStatus
+ );
+
+ /** Bulk mark all unread as read for a user. */
+ default int markAllRead(UUID userId) {
+ return updateStatusForUser(userId, RecipientStatus.UNREAD, RecipientStatus.READ);
+ }
+
+ /** Mark a single notification as read for a user, regardless of current status. */
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("""
+ UPDATE NotificationRecipientEntity r
+ SET r.status = 'READ', r.readAt = CURRENT_TIMESTAMP
+ WHERE r.userId = :userId AND r.notification.id = :notificationId
+ """)
+ int markOneRead(@Param("userId") UUID userId,
+ @Param("notificationId") UUID notificationId);
+
+
+ @Modifying
+ @Query("delete from NotificationRecipientEntity r where r.userId = :userId and r.notification.id = :notificationId")
+ int deleteByUserIdAndNotificationId(@Param("userId") UUID userId,
+ @Param("notificationId") UUID notificationId);
+
+ @Modifying
+ @Query("delete from NotificationRecipientEntity r where r.userId = :userId")
+ int deleteAllByUserId(@Param("userId") UUID userId);
+
+ @Query("select count(r) from NotificationRecipientEntity r where r.notification.id = :notificationId")
+ long countByNotificationId(@Param("notificationId") UUID notificationId);
+
+ @Query("select distinct r.notification.id from NotificationRecipientEntity r where r.userId = :userId")
+ List findNotificationIdsByUserId(@Param("userId") UUID userId);
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/TemplateRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRepository.java
similarity index 59%
rename from src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/TemplateRepository.java
rename to src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRepository.java
index b7ef1bc..70fd2c8 100644
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/TemplateRepository.java
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/persistence/repository/NotificationRepository.java
@@ -1,14 +1,15 @@
package de.unistuttgart.iste.meitrex.notification_service.persistence.repository;
-import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.TemplateEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
-import java.util.Optional;
+import java.util.UUID;
@Repository
-public interface TemplateRepository extends JpaRepository {
-
- Optional findByName(String name);
+public interface NotificationRepository extends JpaRepository {
}
+
+
+
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationService.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationService.java
new file mode 100644
index 0000000..c947809
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationService.java
@@ -0,0 +1,365 @@
+package de.unistuttgart.iste.meitrex.notification_service.service;
+
+import de.unistuttgart.iste.meitrex.common.event.NotificationEvent;
+import de.unistuttgart.iste.meitrex.common.event.ServerSource;
+import de.unistuttgart.iste.meitrex.course_service.client.CourseServiceClient;
+import de.unistuttgart.iste.meitrex.generated.dto.CourseMembership;
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.generated.dto.Settings;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.generated.dto.UserRoleInCourse;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.mapper.NotificationMapper;
+import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.reactivestreams.Publisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import reactor.core.publisher.Sinks;
+
+import java.time.OffsetDateTime;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Core domain service for notifications: event handling, listing, read state, and live streaming.
+ * Recipient status is decided per user from their settings and the event's serverSource.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NotificationService {
+
+ private final NotificationRepository notificationRepository;
+ private final NotificationRecipientRepository recipientRepository;
+ private final NotificationMapper notificationMapper;
+
+ private final CourseServiceClient courseServiceClient;
+ private final SettingsServiceClient settingsServiceClient;
+
+ private final ConcurrentMap> sinks = new ConcurrentHashMap<>();
+
+ private static final EnumSet LECTURE_SOURCES =
+ EnumSet.of(ServerSource.COURSE, ServerSource.CHAPTER, ServerSource.CONTENT, ServerSource.MEDIA);
+
+ /**
+ * Returns a per-user stream for GraphQL subscription.
+ *
+ * @param userId user id
+ * @return publisher emitting NotificationData for this user
+ */
+ public Publisher notificationAddedStream(final UUID userId) {
+ return sinks.computeIfAbsent(userId, k -> Sinks.many().multicast().onBackpressureBuffer())
+ .asFlux();
+ }
+
+ /**
+ * Publishes a newly created notification to a specific user's stream if present.
+ *
+ * @param userId user id
+ * @param dto notification dto
+ */
+ private void publishToUser(final UUID userId, final NotificationData dto) {
+ final var sink = sinks.get(userId);
+ if (sink != null) {
+ sink.tryEmitNext(dto);
+ }
+ }
+
+ /**
+ * Returns all unread notifications count for the given user
+ *
+ * @param userId user id
+ * @return count of unread NotificationData
+ */
+ @Transactional(readOnly = true)
+ public int countUnread(final UUID userId) {
+ return recipientRepository.countUnread(userId);
+ }
+
+ /**
+ * Returns all notifications for the given user excluding DO_NOT_NOTIFY entries.
+ * The "read" flag is derived from recipient status.
+ *
+ * @param userId user id
+ * @return list of NotificationData
+ */
+ @Transactional(readOnly = true)
+ public List getNotificationsForUser(final UUID userId) {
+ return recipientRepository
+ .findAllByUserIdAndStatusNotOrderByCreatedAtDesc(userId, RecipientStatus.DO_NOT_NOTIFY)
+ .stream()
+ .map(rec -> {
+ final var dto = notificationMapper.entityToDto(rec.getNotification());
+ dto.setRead(rec.getStatus() != RecipientStatus.UNREAD);
+ return dto;
+ })
+ .toList();
+ }
+
+ /**
+ * Marks all unread notifications as read for the given user.
+ *
+ * @param userId user id
+ * @return affected rows
+ */
+ @Transactional
+ public int markAllRead(final UUID userId) {
+ return recipientRepository.markAllRead(userId);
+ }
+
+ /**
+ * Marks a single notification as read for the given user.
+ *
+ * @param userId user id
+ * @param notificationId notification id
+ * @return 0 or 1 depending on whether a row was affected
+ */
+ @Transactional
+ public int markOneRead(final UUID userId, final UUID notificationId) {
+ return recipientRepository.markOneRead(userId, notificationId);
+ }
+
+ /**
+ * Handles an incoming NotificationEvent: resolves recipients, fetches settings, persists per-user status,
+ * and publishes to subscribers (UNREAD only).
+ *
+ * @param event incoming event
+ */
+ @Transactional
+ public void handleNotificationEvent(final NotificationEvent event) {
+ if (event == null) {
+ return;
+ }
+
+ final List candidates = resolveRecipients(event);
+ if (candidates.isEmpty()) {
+ log.info("No recipients resolved for event: {}", safeEventTitle(event));
+ return;
+ }
+
+ final Map settingsByUser = fetchSettingsForUsers(candidates);
+
+ final String baseTitle = nvl(event.getTitle(), "Notification");
+ String finalTitle = baseTitle;
+ if (event.getCourseId() != null) {
+ final String courseName = getCourseName(event.getCourseId());
+ if (courseName != null) {
+ finalTitle = "[" + courseName + "] " + baseTitle;
+ }
+ }
+
+ final NotificationEntity saved = notificationRepository.save(
+ NotificationEntity.builder()
+ .title(finalTitle)
+ .description(nvl(event.getMessage(), ""))
+ .href(nvl(event.getLink(), "/"))
+ .createdAt(event.getTimestamp() != null ? event.getTimestamp() : OffsetDateTime.now())
+ .build()
+ );
+
+
+ final ServerSource source = event.getServerSource();
+ final List rows = candidates.stream()
+ .map(uid -> {
+ final Settings s = settingsByUser.get(uid);
+ final RecipientStatus status = decideStatusForUser(s, source);
+ return NotificationRecipientEntity.builder()
+ .userId(uid)
+ .notification(saved)
+ .status(status)
+ .build();
+ })
+ .toList();
+
+ recipientRepository.saveAll(rows);
+
+ final NotificationData dto = notificationMapper.entityToDto(saved);
+ dto.setRead(false);
+ rows.stream()
+ .filter(r -> r.getStatus() == RecipientStatus.UNREAD)
+ .forEach(r -> publishToUser(r.getUserId(), dto));
+ }
+
+ /**
+ * Resolves candidate recipients from the event: prefers explicit userIds; otherwise by course memberships.
+ *
+ * @param event incoming event
+ * @return list of userIds
+ */
+ private List resolveRecipients(final NotificationEvent event) {
+ if (event.getUserIds() != null && !event.getUserIds().isEmpty()) {
+ return event.getUserIds();
+ }
+ if (event.getCourseId() != null) {
+ return resolveRecipientsFromCourse(event.getCourseId());
+ }
+ return List.of();
+ }
+
+ /**
+ * Resolves userIds in a course by querying memberships from course-service.
+ *
+ * @param courseId course id
+ * @return distinct user ids or empty list on failure
+ */
+ private List resolveRecipientsFromCourse(final UUID courseId) {
+ try {
+ final List memberships = courseServiceClient.queryMembershipsInCourse(courseId);
+ if (memberships == null || memberships.isEmpty()) {
+ return List.of();
+ }
+ return memberships.stream()
+ .map(this::membershipUserId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ } catch (final Exception e) {
+ log.warn("Failed to query memberships for courseId={}: {}", courseId, e.getMessage());
+ return List.of();
+ }
+ }
+
+ /**
+ * Resolves userIds in a course by querying memberships from course-service.
+ *
+ * @param courseId course id
+ * @return course name or empty list on failure
+ */
+ private String getCourseName(final UUID courseId) {
+ if (courseId == null) {
+ return null;
+ }
+ try {
+ final de.unistuttgart.iste.meitrex.generated.dto.Course c =
+ courseServiceClient.queryCourseById(courseId);
+ final String title = (c != null) ? c.getTitle() : null;
+ return (title != null && !title.isBlank()) ? title : null;
+ } catch (final Exception e) {
+ log.warn("Failed to query course title for courseId={}: {}", courseId, e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Extracts userId from CourseMembership and converts it to UUID.
+ *
+ * @param m membership dto
+ * @return userId as UUID or null if unparsable
+ */
+ private UUID membershipUserId(final CourseMembership m) {
+ if (m == null) return null;
+ try {
+ final Object uid = m.getUserId();
+ if (uid instanceof UUID u) return u;
+ if (uid instanceof String s) return UUID.fromString(s);
+ } catch (final Exception ignored) { }
+ return null;
+ }
+
+ /**
+ * Decides the recipient status from user's settings and the server source.
+ * - Lecture sources require notification.lecture=true
+ * - Non-lecture sources require notification.gamification=true
+ * - Missing settings default to UNREAD (allow)
+ *
+ * @param settings user settings (may be null)
+ * @param source event's server source
+ * @return resulting status
+ */
+ private RecipientStatus decideStatusForUser(final Settings settings, final ServerSource source) {
+ if (settings == null || settings.getNotification() == null) {
+ return RecipientStatus.UNREAD;
+ }
+
+ final de.unistuttgart.iste.meitrex.generated.dto.Notification notificationData =
+ settings.getNotification();
+
+ final Boolean lecture = notificationData.getLecture();
+ final Boolean gamification = notificationData.getGamification();
+ log.info("he want to sub notify: {}", lecture);
+ if (source != null && LECTURE_SOURCES.contains(source)) {
+ return Boolean.TRUE.equals(lecture) ? RecipientStatus.UNREAD : RecipientStatus.DO_NOT_NOTIFY;
+ } else {
+ return Boolean.TRUE.equals(gamification) ? RecipientStatus.UNREAD : RecipientStatus.DO_NOT_NOTIFY;
+ }
+ }
+
+ /**
+ * Fetches settings for a batch of users by calling user-service per user.
+ * Missing or failed entries are omitted (treated as default allow).
+ *
+ * @param userIds user ids
+ * @return map userId -> Settings
+ */
+ private Map fetchSettingsForUsers(final List userIds) {
+ final Map map = new HashMap<>(userIds.size());
+ for (UUID uid : userIds) {
+ try {
+ final Settings s = settingsServiceClient.queryUserSettings(uid);
+ if (s != null) {
+ map.put(uid, s);
+ }
+ } catch (final Exception ex) {
+ log.warn("Failed to fetch settings for userId={}: {}", uid, ex.getMessage());
+ }
+ }
+ return map;
+ }
+
+ private String nvl(final String s, final String d) {
+ return (s == null || s.isBlank()) ? d : s;
+ }
+
+ private String safeEventTitle(final NotificationEvent e) {
+ return (e != null && e.getTitle() != null) ? e.getTitle() : "";
+ }
+
+ /**
+ * delete one notificationrecipient by userid and notificationId
+ * @param userId
+ * @param notificationId
+ * @return
+ */
+ @Transactional
+ public int deleteOne(final UUID userId, final UUID notificationId) {
+ if (userId == null || notificationId == null) return 0;
+ final int affected = recipientRepository.deleteByUserIdAndNotificationId(userId, notificationId);
+ if (affected > 0) {
+ final long cnt = recipientRepository.countByNotificationId(notificationId);
+ if (cnt == 0L) {
+ try { notificationRepository.deleteById(notificationId); } catch (Exception ignored) {}
+ }
+ }
+ return affected;
+ }
+
+ /**
+ * delete all notificationrecipients by userid
+ * @param userId
+ * @return
+ */
+ @Transactional
+ public int deleteAll(final UUID userId) {
+ if (userId == null) return 0;
+
+ final List toCheck = recipientRepository.findNotificationIdsByUserId(userId);
+ final int affected = recipientRepository.deleteAllByUserId(userId);
+
+ for (UUID nid : toCheck) {
+ if (nid == null) continue;
+ final long cnt = recipientRepository.countByNotificationId(nid);
+ if (cnt == 0L) {
+ try { notificationRepository.deleteById(nid); } catch (Exception ignored) {}
+ }
+ }
+ return affected;
+ }
+
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/TemplateService.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/TemplateService.java
deleted file mode 100644
index 3021d2e..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/service/TemplateService.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package de.unistuttgart.iste.meitrex.notification_service.service;
-
-import de.unistuttgart.iste.meitrex.generated.dto.Template;
-import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.TemplateEntity;
-import de.unistuttgart.iste.meitrex.notification_service.persistence.mapper.TemplateMapper;
-import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.TemplateRepository;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
-
-import java.util.List;
-
-@Service
-@RequiredArgsConstructor
-public class TemplateService {
-
- private final TemplateRepository notification_serviceRepository;
- private final TemplateMapper notification_serviceMapper;
-
- public List getAllTemplates() {
- List notification_services = notification_serviceRepository.findAll();
- return notification_services.stream().map(notification_serviceMapper::entityToDto).toList();
- }
-
-}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/TemplateValidator.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/TemplateValidator.java
deleted file mode 100644
index bc182a8..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/TemplateValidator.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.unistuttgart.iste.meitrex.notification_service.validation;
-
-import de.unistuttgart.iste.meitrex.generated.dto.Template;
-import org.springframework.stereotype.Component;
-
-@Component
-public class TemplateValidator {
-
- public void validateTemplate(Template notification_service) {
- // add validation logic here
- }
-}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/package-info.java b/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/package-info.java
deleted file mode 100644
index f4d9eeb..0000000
--- a/src/main/java/de/unistuttgart/iste/meitrex/notification_service/validation/package-info.java
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Put class level validation logic in this package.
- */
-package de.unistuttgart.iste.meitrex.notification_service.validation;
\ No newline at end of file
diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties
index 0cc2952..ae321ad 100644
--- a/src/main/resources/application-prod.properties
+++ b/src/main/resources/application-prod.properties
@@ -9,4 +9,7 @@ spring.jpa.show-sql=false
spring.sql.init.continue-on-error=true
spring.jpa.hibernate.ddl-auto=update
+course_service.url=http://app-course:2001/graphql
+user_service.url=http://app-user:5001/graphql
+
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index cab3220..5cc3cff 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -8,6 +8,8 @@ spring.graphql.graphiql.path=/graphiql
server.port=3101
dapr.appId=notification_service
dapr.port=3100
+course_service.url=http://localhost:2001/graphql
+user_service.url=http://localhost:5001/graphql
# increase max request header size because we transmit authorization data in the header and this might get large
# if the user is in a lot of courses
diff --git a/src/main/resources/graphql/service/notification.graphqls b/src/main/resources/graphql/service/notification.graphqls
new file mode 100644
index 0000000..d182e62
--- /dev/null
+++ b/src/main/resources/graphql/service/notification.graphqls
@@ -0,0 +1,32 @@
+# schema file of the microservice
+
+# TODO define data types, queries and mutations
+# this can be done in a separate files as long as they are in this folder and
+# end with .graphqls
+
+type NotificationData {
+ id: UUID!
+ title: String!
+ description: String!
+ href: String!
+ createdAt: DateTime!
+ read: Boolean!
+}
+
+type Query {
+ notifications(userId: UUID!): [NotificationData !]!
+ countUnread(userId: UUID!): Int!
+}
+
+
+type Subscription {
+ notificationAdded(userId: UUID!): NotificationData !
+}
+
+
+type Mutation {
+ markAllRead(userId: UUID!): Int!
+ markOneRead(userId: UUID!, notificationId: UUID!): Int!
+ deleteOneNotification(userId: UUID!, notificationId: UUID!): Int!
+ deleteAllNotifications(userId: UUID!): Int!
+}
\ No newline at end of file
diff --git a/src/main/resources/graphql/service/schema.graphqls b/src/main/resources/graphql/service/schema.graphqls
deleted file mode 100644
index e6d01f3..0000000
--- a/src/main/resources/graphql/service/schema.graphqls
+++ /dev/null
@@ -1,16 +0,0 @@
-# schema file of the microservice
-
-# TODO define data types, queries and mutations
-# this can be done in a separate files as long as they are in this folder and
-# end with .graphqls
-
-
-# example
-type Template {
- id: ID!
- name: String!
-}
-
-type Query {
- templates: [Template]
-}
\ No newline at end of file
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteAllNotificationsTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteAllNotificationsTest.java
new file mode 100644
index 0000000..6c9b366
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteAllNotificationsTest.java
@@ -0,0 +1,88 @@
+package de.unistuttgart.iste.meitrex.notification_service.api;
+
+import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest;
+import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader;
+import de.unistuttgart.iste.meitrex.common.testutil.TablesToDelete;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.testconfig.MockDownstreamClientsConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.graphql.test.tester.GraphQlTester;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@GraphQlApiTest
+@Import(MockDownstreamClientsConfiguration.class)
+@TablesToDelete({"notification_recipient","notification"})
+class MutationDeleteAllNotificationsTest {
+
+ @InjectCurrentUserHeader
+ LoggedInUser currentUser;
+
+ @Autowired NotificationRepository notificationRepository;
+ @Autowired NotificationRecipientRepository recipientRepository;
+
+ @BeforeEach
+ void initUser() {
+ if (currentUser == null) {
+ currentUser = LoggedInUser.builder()
+ .id(UUID.randomUUID())
+ .userName("test")
+ .firstName("test")
+ .lastName("test")
+ .nickname("test")
+ .courseMemberships(List.of())
+ .realmRoles(Set.of())
+ .build();
+ }
+ }
+
+ @Test
+ void deleteAllNotifications_removesOnlyMyRecipients_andCleansOrphans(GraphQlTester graphQlTester) {
+ UUID me = currentUser.getId();
+ UUID other = UUID.randomUUID();
+
+ NotificationEntity onlyMine = notificationRepository.save(NotificationEntity.builder()
+ .title("T1")
+ .description("M1")
+ .href("/l1")
+ .createdAt(OffsetDateTime.now())
+ .build());
+ NotificationEntity shared = notificationRepository.save(NotificationEntity.builder()
+ .title("T2")
+ .description("M2")
+ .href("/l2")
+ .createdAt(OffsetDateTime.now())
+ .build());
+
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(onlyMine).userId(me).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(shared).userId(me).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(shared).userId(other).status(RecipientStatus.UNREAD).build());
+
+ String doc = "mutation($uid: UUID!){ deleteAllNotifications(userId:$uid) }";
+
+ graphQlTester.document(doc)
+ .variable("uid", me)
+ .execute()
+ .path("deleteAllNotifications").entity(Integer.class).isEqualTo(2);
+
+ List remainingRecipients = recipientRepository.findAll();
+ assertThat(remainingRecipients).hasSize(1);
+ assertThat(remainingRecipients.get(0).getUserId()).isEqualTo(other);
+
+ assertThat(notificationRepository.findById(onlyMine.getId())).isNotPresent();
+ assertThat(notificationRepository.findById(shared.getId())).isPresent();
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteOneNotificationTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteOneNotificationTest.java
new file mode 100644
index 0000000..265b581
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationDeleteOneNotificationTest.java
@@ -0,0 +1,89 @@
+package de.unistuttgart.iste.meitrex.notification_service.api;
+
+import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest;
+import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader;
+import de.unistuttgart.iste.meitrex.common.testutil.TablesToDelete;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.testconfig.MockDownstreamClientsConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.graphql.test.tester.GraphQlTester;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@GraphQlApiTest
+@Import(MockDownstreamClientsConfiguration.class)
+@TablesToDelete({"notification_recipient","notification"})
+class MutationDeleteOneNotificationTest {
+
+ @InjectCurrentUserHeader
+ LoggedInUser currentUser;
+
+ @Autowired
+ NotificationRepository notificationRepository;
+
+ @Autowired
+ NotificationRecipientRepository recipientRepository;
+
+ @BeforeEach
+ void initUser() {
+ if (currentUser == null) {
+ currentUser = LoggedInUser.builder()
+ .id(UUID.randomUUID())
+ .userName("test")
+ .firstName("test")
+ .lastName("test")
+ .nickname("test")
+ .courseMemberships(List.of())
+ .realmRoles(Set.of())
+ .build();
+ }
+ }
+
+ @Test
+ void shouldDeleteOne(GraphQlTester graphQlTester) {
+ UUID uid = currentUser.getId();
+
+ NotificationEntity n1 = notificationRepository.save(NotificationEntity.builder()
+ .title("T1")
+ .description("M1")
+ .href("/l1")
+ .createdAt(OffsetDateTime.now())
+ .build());
+ NotificationEntity n2 = notificationRepository.save(NotificationEntity.builder()
+ .title("T2")
+ .description("M2")
+ .href("/l2")
+ .createdAt(OffsetDateTime.now())
+ .build());
+
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n1).userId(uid).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n2).userId(uid).status(RecipientStatus.UNREAD).build());
+
+ graphQlTester.document("mutation($uid: UUID!, $nid: UUID!){ deleteOneNotification(userId:$uid, notificationId:$nid) }")
+ .variable("uid", uid)
+ .variable("nid", n1.getId())
+ .execute()
+ .path("deleteOneNotification").entity(Integer.class).isEqualTo(1);
+
+ List titles = graphQlTester.document("query($uid: UUID!){ notifications(userId:$uid){ title } }")
+ .variable("uid", uid)
+ .execute()
+ .path("notifications[*].title").entityList(String.class).get();
+
+ assertThat(titles).contains("T2");
+ assertThat(titles).doesNotContain("T1");
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkAllReadTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkAllReadTest.java
new file mode 100644
index 0000000..e8f35fe
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkAllReadTest.java
@@ -0,0 +1,92 @@
+package de.unistuttgart.iste.meitrex.notification_service.api;
+
+import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest;
+import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader;
+import de.unistuttgart.iste.meitrex.common.testutil.TablesToDelete;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.testconfig.MockDownstreamClientsConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.graphql.test.tester.GraphQlTester;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@GraphQlApiTest
+@Import(MockDownstreamClientsConfiguration.class)
+@TablesToDelete({"notification_recipient","notification"})
+class MutationMarkAllReadTest {
+
+ @InjectCurrentUserHeader
+ LoggedInUser currentUser;
+
+ @Autowired
+ NotificationRepository notificationRepository;
+
+ @Autowired
+ NotificationRecipientRepository recipientRepository;
+
+ @BeforeEach
+ void initUser() {
+ if (currentUser == null) {
+ currentUser = LoggedInUser.builder()
+ .id(UUID.randomUUID())
+ .userName("test")
+ .firstName("test")
+ .lastName("test")
+ .nickname("test")
+ .courseMemberships(List.of())
+ .realmRoles(Set.of())
+ .build();
+ }
+ }
+
+ @Test
+ void markAllRead_updatesUnreadToRead(GraphQlTester graphQlTester) {
+ UUID uid = currentUser.getId();
+
+ NotificationEntity n1 = notificationRepository.save(NotificationEntity.builder()
+ .title("T1")
+ .description("M1")
+ .href("/l1")
+ .createdAt(OffsetDateTime.now())
+ .build());
+ NotificationEntity n2 = notificationRepository.save(NotificationEntity.builder()
+ .title("T2")
+ .description("M2")
+ .href("/l2")
+ .createdAt(OffsetDateTime.now())
+ .build());
+
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n1).userId(uid).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n2).userId(uid).status(RecipientStatus.UNREAD).build());
+
+ Integer unreadBefore = graphQlTester.document("query($uid: UUID!){ countUnread(userId:$uid) }")
+ .variable("uid", uid)
+ .execute()
+ .path("countUnread").entity(Integer.class).get();
+ assertThat(unreadBefore).isEqualTo(2);
+
+ graphQlTester.document("mutation($uid: UUID!){ markAllRead(userId: $uid) }")
+ .variable("uid", uid)
+ .execute()
+ .path("markAllRead").entity(Integer.class).isEqualTo(2);
+
+ Integer unreadAfter = graphQlTester.document("query($uid: UUID!){ countUnread(userId:$uid) }")
+ .variable("uid", uid)
+ .execute()
+ .path("countUnread").entity(Integer.class).get();
+ assertThat(unreadAfter).isEqualTo(0);
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkOneReadTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkOneReadTest.java
new file mode 100644
index 0000000..43b44eb
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/MutationMarkOneReadTest.java
@@ -0,0 +1,76 @@
+package de.unistuttgart.iste.meitrex.notification_service.api;
+
+import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest;
+import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader;
+import de.unistuttgart.iste.meitrex.common.testutil.TablesToDelete;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.testconfig.MockDownstreamClientsConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.graphql.test.tester.GraphQlTester;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@GraphQlApiTest
+@Import(MockDownstreamClientsConfiguration.class)
+@TablesToDelete({"notification_recipient","notification"})
+class MutationMarkOneReadTest {
+
+ @InjectCurrentUserHeader
+ LoggedInUser currentUser;
+
+ @Autowired NotificationRepository notificationRepository;
+ @Autowired NotificationRecipientRepository recipientRepository;
+
+ @BeforeEach
+ void initUser() {
+ if (currentUser == null) {
+ currentUser = LoggedInUser.builder()
+ .id(UUID.randomUUID())
+ .userName("test")
+ .firstName("test")
+ .lastName("test")
+ .nickname("test")
+ .courseMemberships(List.of())
+ .realmRoles(Set.of())
+ .build();
+ }
+ }
+
+ @Test
+ void markOneRead_updates_only_one(GraphQlTester graphQlTester) {
+ UUID uid = currentUser.getId();
+
+ NotificationEntity n1 = notificationRepository.save(NotificationEntity.builder()
+ .title("A").description("a").href("/a").createdAt(OffsetDateTime.now()).build());
+ NotificationEntity n2 = notificationRepository.save(NotificationEntity.builder()
+ .title("B").description("b").href("/b").createdAt(OffsetDateTime.now()).build());
+
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n1).userId(uid).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n2).userId(uid).status(RecipientStatus.UNREAD).build());
+
+ Integer before = graphQlTester.document("query($uid: UUID!){ countUnread(userId:$uid) }")
+ .variable("uid", uid).execute().path("countUnread").entity(Integer.class).get();
+ assertThat(before).isEqualTo(2);
+
+ graphQlTester.document("mutation($uid: UUID!, $nid: UUID!){ markOneRead(userId:$uid, notificationId:$nid) }")
+ .variable("uid", uid).variable("nid", n1.getId()).execute()
+ .path("markOneRead").entity(Integer.class).isEqualTo(1);
+
+ Integer after = graphQlTester.document("query($uid: UUID!){ countUnread(userId:$uid) }")
+ .variable("uid", uid).execute().path("countUnread").entity(Integer.class).get();
+ assertThat(after).isEqualTo(1);
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/QueryNotificationsTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/QueryNotificationsTest.java
new file mode 100644
index 0000000..74279b3
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/api/QueryNotificationsTest.java
@@ -0,0 +1,94 @@
+package de.unistuttgart.iste.meitrex.notification_service.api;
+
+import de.unistuttgart.iste.meitrex.common.testutil.GraphQlApiTest;
+import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader;
+import de.unistuttgart.iste.meitrex.common.testutil.TablesToDelete;
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.notification_service.testconfig.MockDownstreamClientsConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Import;
+import org.springframework.graphql.test.tester.GraphQlTester;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@GraphQlApiTest
+@Import(MockDownstreamClientsConfiguration.class)
+@TablesToDelete({"notification_recipient","notification"})
+class QueryNotificationsTest {
+
+ @InjectCurrentUserHeader
+ LoggedInUser currentUser;
+
+ @Autowired
+ NotificationRepository notificationRepository;
+
+ @Autowired
+ NotificationRecipientRepository recipientRepository;
+
+ @BeforeEach
+ void initUser() {
+ if (currentUser == null) {
+ currentUser = LoggedInUser.builder()
+ .id(UUID.randomUUID())
+ .userName("test")
+ .firstName("test")
+ .lastName("test")
+ .nickname("test")
+ .courseMemberships(List.of())
+ .realmRoles(Set.of())
+ .build();
+ }
+ }
+
+ @Test
+ void shouldListMyNotifications(GraphQlTester graphQlTester) {
+ UUID uid = currentUser.getId();
+
+ NotificationEntity n1 = NotificationEntity.builder()
+ .title("T1")
+ .description("M1")
+ .href("/l1")
+ .createdAt(OffsetDateTime.now())
+ .build();
+ NotificationEntity n2 = NotificationEntity.builder()
+ .title("T2")
+ .description("M2")
+ .href("/l2")
+ .createdAt(OffsetDateTime.now())
+ .build();
+ n1 = notificationRepository.save(n1);
+ n2 = notificationRepository.save(n2);
+
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n1).userId(uid).status(RecipientStatus.UNREAD).build());
+ recipientRepository.save(NotificationRecipientEntity.builder().notification(n2).userId(uid).status(RecipientStatus.READ).build());
+
+ Integer unread = graphQlTester.document("query($uid: UUID!){ countUnread(userId:$uid) }")
+ .variable("uid", uid)
+ .execute()
+ .path("countUnread").entity(Integer.class).get();
+ assertThat(unread).isEqualTo(1);
+
+ var res = graphQlTester.document("query($uid: UUID!){ notifications(userId:$uid){ title read } }")
+ .variable("uid", uid)
+ .execute();
+
+ List titles = res.path("notifications[*].title").entityList(String.class).get();
+ assertThat(titles).contains("T1", "T2");
+
+ List reads = res.path("notifications[*].read").entityList(Boolean.class).get();
+ assertThat(reads).contains(true);
+ assertThat(reads).contains(false);
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationControllerTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationControllerTest.java
new file mode 100644
index 0000000..4b2bd3d
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/NotificationControllerTest.java
@@ -0,0 +1,79 @@
+package de.unistuttgart.iste.meitrex.notification_service.controller;
+
+import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser;
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.notification_service.service.NotificationService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.reactivestreams.Publisher;
+
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class NotificationControllerTest {
+
+ @Mock NotificationService service;
+ @Mock LoggedInUser currentUser;
+
+ @Test
+ void countUnread_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ when(service.countUnread(uid)).thenReturn(4);
+ assertThat(c.countUnread(uid, currentUser)).isEqualTo(4);
+ }
+
+ @Test
+ void notificationAdded_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ Publisher p = subscriber -> {};
+ when(service.notificationAddedStream(uid)).thenReturn(p);
+ assertThat(c.notificationAdded(uid, currentUser)).isSameAs(p);
+ }
+
+ @Test
+ void deleteAll_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ when(service.deleteAll(uid)).thenReturn(7);
+ assertThat(c.deleteAllNotifications(uid, currentUser)).isEqualTo(7);
+ }
+
+ @Test
+ void deleteOne_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ var nid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ when(service.deleteOne(uid, nid)).thenReturn(1);
+ assertThat(c.deleteOneNotification(uid, nid, currentUser)).isEqualTo(1);
+ }
+
+ @Test
+ void markAllRead_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ when(service.markAllRead(uid)).thenReturn(9);
+ assertThat(c.markAllRead(uid, currentUser)).isEqualTo(9);
+ }
+
+ @Test
+ void markOneRead_delegates() {
+ var c = new NotificationController(service);
+ var uid = UUID.randomUUID();
+ var nid = UUID.randomUUID();
+ when(currentUser.getId()).thenReturn(uid);
+ when(service.markOneRead(uid, nid)).thenReturn(1);
+ assertThat(c.markOneRead(uid, nid, currentUser)).isEqualTo(1);
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionControllerTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionControllerTest.java
new file mode 100644
index 0000000..8456607
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/controller/SubscriptionControllerTest.java
@@ -0,0 +1,40 @@
+package de.unistuttgart.iste.meitrex.notification_service.controller;
+
+import de.unistuttgart.iste.meitrex.common.event.NotificationEvent;
+import de.unistuttgart.iste.meitrex.notification_service.service.NotificationService;
+import io.dapr.client.domain.CloudEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class SubscriptionControllerTest {
+
+ @Mock
+ NotificationService service;
+
+ @Test
+ void handleNotificationEvent_delegatesToService_andCompletes() {
+ SubscriptionController c = new SubscriptionController(service);
+
+ NotificationEvent e = new NotificationEvent();
+ e.setTitle("T"); e.setMessage("M"); e.setLink("/x");
+
+ CloudEvent ce = new CloudEvent<>();
+ ce.setData(e);
+
+ Mono out = c.handleNotificationEvent(ce, Map.of());
+ out.block();
+
+ verify(service).handleNotificationEvent(e);
+ verifyNoMoreInteractions(service);
+ assertThat(out).isNotNull();
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapperTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapperTest.java
new file mode 100644
index 0000000..8afac7c
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/persistence/mapper/NotificationMapperTest.java
@@ -0,0 +1,47 @@
+package de.unistuttgart.iste.meitrex.notification_service.persistence.mapper;
+
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NotificationMapperTest {
+
+ @Test
+ void entityToDto_mapsFields() {
+ NotificationEntity e = NotificationEntity.builder()
+ .id(UUID.randomUUID())
+ .title("T")
+ .description("D")
+ .href("/h")
+ .createdAt(OffsetDateTime.now())
+ .build();
+
+ NotificationMapper mapper = new NotificationMapper();
+ NotificationData d = mapper.entityToDto(e);
+
+ assertThat(d.getId()).isEqualTo(e.getId());
+ assertThat(d.getTitle()).isEqualTo("T");
+ assertThat(d.getDescription()).isEqualTo("D");
+ assertThat(d.getHref()).isEqualTo("/h");
+ assertThat(d.getCreatedAt()).isEqualTo(e.getCreatedAt());
+ }
+
+ @Test
+ void entityToDto_handlesNulls() {
+ NotificationEntity e = NotificationEntity.builder().build();
+
+ NotificationMapper mapper = new NotificationMapper();
+ NotificationData d = mapper.entityToDto(e);
+
+ assertThat(d.getId()).isNull();
+ assertThat(d.getTitle()).isNull();
+ assertThat(d.getDescription()).isNull();
+ assertThat(d.getHref()).isNull();
+ assertThat(d.getCreatedAt()).isNull();
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationServiceTest.java
new file mode 100644
index 0000000..21daa5f
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/service/NotificationServiceTest.java
@@ -0,0 +1,316 @@
+package de.unistuttgart.iste.meitrex.notification_service.service;
+
+import de.unistuttgart.iste.meitrex.common.event.NotificationEvent;
+import de.unistuttgart.iste.meitrex.common.event.ServerSource;
+import de.unistuttgart.iste.meitrex.course_service.client.CourseServiceClient;
+import de.unistuttgart.iste.meitrex.course_service.exception.CourseServiceConnectionException;
+import de.unistuttgart.iste.meitrex.generated.dto.NotificationData;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.entity.NotificationRecipientEntity.RecipientStatus;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.mapper.NotificationMapper;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRecipientRepository;
+import de.unistuttgart.iste.meitrex.notification_service.persistence.repository.NotificationRepository;
+import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentMatcher;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.reactivestreams.Publisher;
+
+import java.time.OffsetDateTime;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class NotificationServiceTest {
+
+ @Mock NotificationRepository notificationRepository;
+ @Mock NotificationRecipientRepository recipientRepository;
+ @Mock NotificationMapper notificationMapper;
+ @Mock CourseServiceClient courseServiceClient;
+ @Mock SettingsServiceClient settingsServiceClient;
+
+ NotificationService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new NotificationService(
+ notificationRepository, recipientRepository, notificationMapper,
+ courseServiceClient, settingsServiceClient
+ );
+ when(notificationRepository.save(any())).thenAnswer(inv -> {
+ NotificationEntity in = inv.getArgument(0);
+ return NotificationEntity.builder()
+ .id(UUID.randomUUID())
+ .title(in.getTitle())
+ .description(in.getDescription())
+ .href(in.getHref())
+ .createdAt(OffsetDateTime.now())
+ .build();
+ });
+ when(notificationMapper.entityToDto(any(NotificationEntity.class))).thenAnswer(inv -> {
+ NotificationEntity e = inv.getArgument(0);
+ NotificationData d = new NotificationData();
+ d.setId(e.getId());
+ d.setTitle(e.getTitle());
+ d.setDescription(e.getDescription());
+ d.setHref(e.getHref());
+ d.setCreatedAt(e.getCreatedAt());
+ d.setRead(false);
+ return d;
+ });
+ }
+
+ private NotificationEntity entity(UUID id, String title, String desc, String href) {
+ return NotificationEntity.builder()
+ .id(id).title(title).description(desc).href(href)
+ .createdAt(OffsetDateTime.now())
+ .build();
+ }
+
+ @Test
+ void countUnread_delegates() {
+ UUID uid = UUID.randomUUID();
+ when(recipientRepository.countUnread(uid)).thenReturn(5);
+ assertThat(service.countUnread(uid)).isEqualTo(5);
+ verify(recipientRepository).countUnread(uid);
+ }
+
+ @Test
+ void getNotificationsForUser_mapsReadFlag() {
+ UUID uid = UUID.randomUUID();
+ UUID n1 = UUID.randomUUID();
+ UUID n2 = UUID.randomUUID();
+
+ var e1 = entity(n1, "T1", "D1", "/a");
+ var e2 = entity(n2, "T2", "D2", "/b");
+
+ var r1 = NotificationRecipientEntity.builder().userId(uid).notification(e1).status(RecipientStatus.UNREAD).build();
+ var r2 = NotificationRecipientEntity.builder().userId(uid).notification(e2).status(RecipientStatus.READ).build();
+
+ when(recipientRepository.findAllByUserIdAndStatusNotOrderByCreatedAtDesc(uid, RecipientStatus.DO_NOT_NOTIFY))
+ .thenReturn(List.of(r1, r2));
+
+ var out = service.getNotificationsForUser(uid);
+ assertThat(out).hasSize(2);
+ var byId = new HashMap();
+ out.forEach(x -> byId.put(x.getId(), x));
+ assertThat(byId.get(n1).getRead()).isFalse();
+ assertThat(byId.get(n2).getRead()).isTrue();
+ }
+
+ @Test
+ void markAllRead_delegates() {
+ UUID uid = UUID.randomUUID();
+ when(recipientRepository.markAllRead(uid)).thenReturn(9);
+ assertThat(service.markAllRead(uid)).isEqualTo(9);
+ verify(recipientRepository).markAllRead(uid);
+ }
+
+ @Test
+ void markOneRead_delegates() {
+ UUID uid = UUID.randomUUID(); UUID nid = UUID.randomUUID();
+ when(recipientRepository.markOneRead(uid, nid)).thenReturn(1);
+ assertThat(service.markOneRead(uid, nid)).isEqualTo(1);
+ verify(recipientRepository).markOneRead(uid, nid);
+ }
+
+ @Test
+ void notificationAddedStream_withExplicitUserIds_emits() {
+ UUID u1 = UUID.randomUUID();
+ UUID u2 = UUID.randomUUID();
+
+ Publisher p1 = service.notificationAddedStream(u1);
+ Publisher p2 = service.notificationAddedStream(u2);
+
+ var receivedU1 = new CopyOnWriteArrayList();
+ var receivedU2 = new CopyOnWriteArrayList();
+
+ p1.subscribe(new org.reactivestreams.Subscriber<>() {
+ public void onSubscribe(org.reactivestreams.Subscription s) { s.request(Long.MAX_VALUE); }
+ public void onNext(NotificationData nd) { receivedU1.add(nd); }
+ public void onError(Throwable t) {}
+ public void onComplete() {}
+ });
+ p2.subscribe(new org.reactivestreams.Subscriber<>() {
+ public void onSubscribe(org.reactivestreams.Subscription s) { s.request(Long.MAX_VALUE); }
+ public void onNext(NotificationData nd) { receivedU2.add(nd); }
+ public void onError(Throwable t) {}
+ public void onComplete() {}
+ });
+
+ var event = new NotificationEvent();
+ event.setUserIds(List.of(u1, u2));
+ event.setServerSource(ServerSource.MEDIA);
+ event.setTitle("T"); event.setMessage("M"); event.setLink("/x");
+
+ service.handleNotificationEvent(event);
+
+ assertThat(receivedU1).hasSize(1);
+ assertThat(receivedU2).hasSize(1);
+
+ verify(recipientRepository).saveAll(argThat((ArgumentMatcher>) rows -> {
+ int cnt=0; int unread=0;
+ for (NotificationRecipientEntity r : rows) { cnt++; if (r.getStatus()==RecipientStatus.UNREAD) unread++; }
+ return cnt==2 && unread==2;
+ }));
+ }
+
+ @Test
+ void notificationAddedStream_multipleEvents_emitInOrder() {
+ UUID u = UUID.randomUUID();
+ Publisher p = service.notificationAddedStream(u);
+ var got = new CopyOnWriteArrayList();
+ p.subscribe(new org.reactivestreams.Subscriber<>() {
+ public void onSubscribe(org.reactivestreams.Subscription s) { s.request(Long.MAX_VALUE); }
+ public void onNext(NotificationData nd) { got.add(nd.getTitle()); }
+ public void onError(Throwable t) {}
+ public void onComplete() {}
+ });
+
+ var e1 = new NotificationEvent();
+ e1.setUserIds(List.of(u)); e1.setServerSource(ServerSource.MEDIA); e1.setTitle("A"); e1.setMessage("a"); e1.setLink("/a");
+ var e2 = new NotificationEvent();
+ e2.setUserIds(List.of(u)); e2.setServerSource(ServerSource.MEDIA); e2.setTitle("B"); e2.setMessage("b"); e2.setLink("/b");
+
+ service.handleNotificationEvent(e1);
+ service.handleNotificationEvent(e2);
+
+ assertThat(got).containsExactly("A", "B");
+ }
+
+ @Test
+ void handleNotificationEvent_nullEvent_noop() {
+ service.handleNotificationEvent(null);
+ verifyNoInteractions(notificationRepository);
+ verifyNoInteractions(recipientRepository);
+ }
+
+ @Test
+ void handleNotificationEvent_noRecipients_noop() {
+ var event = new NotificationEvent();
+ event.setUserIds(Collections.emptyList());
+ event.setCourseId(null);
+ event.setServerSource(ServerSource.MEDIA);
+ event.setTitle("t"); event.setMessage("m"); event.setLink("/x");
+
+ service.handleNotificationEvent(event);
+
+ verifyNoInteractions(notificationRepository);
+ verifyNoInteractions(recipientRepository);
+ }
+
+ @Test
+ void handleNotificationEvent_withCourseId_noMembers_noop() throws CourseServiceConnectionException {
+ UUID courseId = UUID.randomUUID();
+ doReturn(Collections.emptyList()).when(courseServiceClient).queryMembershipsInCourse(courseId);
+
+ var event = new NotificationEvent();
+ event.setCourseId(courseId);
+ event.setServerSource(ServerSource.CONTENT);
+ event.setTitle("T"); event.setMessage("M"); event.setLink("/x");
+
+ service.handleNotificationEvent(event);
+
+ verifyNoInteractions(recipientRepository);
+ verifyNoInteractions(notificationRepository);
+ }
+
+ @Test
+ void handleNotificationEvent_withCourseId_broadcastsAndEmits() throws CourseServiceConnectionException {
+ UUID courseId = UUID.randomUUID();
+ UUID u1 = UUID.randomUUID();
+ UUID u2 = UUID.randomUUID();
+
+ Publisher p1 = service.notificationAddedStream(u1);
+ Publisher p2 = service.notificationAddedStream(u2);
+
+ var got1 = new CopyOnWriteArrayList();
+ var got2 = new CopyOnWriteArrayList();
+
+ p1.subscribe(new org.reactivestreams.Subscriber<>() {
+ public void onSubscribe(org.reactivestreams.Subscription s) { s.request(Long.MAX_VALUE); }
+ public void onNext(NotificationData nd) { got1.add(nd); }
+ public void onError(Throwable t) {}
+ public void onComplete() {}
+ });
+ p2.subscribe(new org.reactivestreams.Subscriber<>() {
+ public void onSubscribe(org.reactivestreams.Subscription s) { s.request(Long.MAX_VALUE); }
+ public void onNext(NotificationData nd) { got2.add(nd); }
+ public void onError(Throwable t) {}
+ public void onComplete() {}
+ });
+
+ var m1 = new de.unistuttgart.iste.meitrex.generated.dto.CourseMembership();
+ m1.setUserId(u1);
+ m1.setRole(de.unistuttgart.iste.meitrex.generated.dto.UserRoleInCourse.STUDENT);
+ var m2 = new de.unistuttgart.iste.meitrex.generated.dto.CourseMembership();
+ m2.setUserId(u2);
+ m2.setRole(de.unistuttgart.iste.meitrex.generated.dto.UserRoleInCourse.TUTOR);
+ doReturn(List.of(m1, m2)).when(courseServiceClient).queryMembershipsInCourse(courseId);
+
+ var event = new NotificationEvent();
+ event.setCourseId(courseId);
+ event.setServerSource(ServerSource.CONTENT);
+ event.setTitle("T"); event.setMessage("M"); event.setLink("/x");
+
+ service.handleNotificationEvent(event);
+
+ assertThat(got1).hasSize(1);
+ assertThat(got2).hasSize(1);
+
+ verify(recipientRepository).saveAll(argThat((ArgumentMatcher>) rows -> {
+ int cnt=0, unread=0;
+ for (NotificationRecipientEntity r : rows) { cnt++; if (r.getStatus()==RecipientStatus.UNREAD) unread++; }
+ return cnt==2 && unread==2;
+ }));
+ }
+
+ @Test
+ void deleteAll_cleansOrphans() {
+ UUID uid = UUID.randomUUID();
+ UUID n1 = UUID.randomUUID();
+ UUID n2 = UUID.randomUUID();
+
+ when(recipientRepository.findNotificationIdsByUserId(uid)).thenReturn(List.of(n1, n2));
+ when(recipientRepository.deleteAllByUserId(uid)).thenReturn(2);
+ when(recipientRepository.countByNotificationId(n1)).thenReturn(0L);
+ when(recipientRepository.countByNotificationId(n2)).thenReturn(3L);
+
+ int affected = service.deleteAll(uid);
+ assertThat(affected).isEqualTo(2);
+ verify(notificationRepository).deleteById(n1);
+ verify(notificationRepository, never()).deleteById(n2);
+ }
+
+ @Test
+ void deleteOne_cleansOrphanWhenLastRecipient() {
+ UUID uid = UUID.randomUUID();
+ UUID nid = UUID.randomUUID();
+ when(recipientRepository.deleteByUserIdAndNotificationId(uid, nid)).thenReturn(1);
+ when(recipientRepository.countByNotificationId(nid)).thenReturn(0L);
+
+ int affected = service.deleteOne(uid, nid);
+ assertThat(affected).isEqualTo(1);
+ verify(notificationRepository).deleteById(nid);
+ }
+
+ @Test
+ void deleteOne_noopWhenParamsNull() {
+ assertThat(service.deleteOne(null, UUID.randomUUID())).isEqualTo(0);
+ assertThat(service.deleteOne(UUID.randomUUID(), null)).isEqualTo(0);
+ verifyNoInteractions(recipientRepository);
+ }
+
+
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/notification_service/testconfig/MockDownstreamClientsConfiguration.java b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/testconfig/MockDownstreamClientsConfiguration.java
new file mode 100644
index 0000000..8a6dde5
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/notification_service/testconfig/MockDownstreamClientsConfiguration.java
@@ -0,0 +1,24 @@
+package de.unistuttgart.iste.meitrex.notification_service.testconfig;
+
+import de.unistuttgart.iste.meitrex.course_service.client.CourseServiceClient;
+import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient;
+import org.mockito.Mockito;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+@TestConfiguration
+public class MockDownstreamClientsConfiguration {
+
+ @Bean
+ @Primary
+ public CourseServiceClient courseServiceClient() {
+ return Mockito.mock(CourseServiceClient.class);
+ }
+
+ @Bean
+ @Primary
+ public SettingsServiceClient settingsServiceClient() {
+ return Mockito.mock(SettingsServiceClient.class);
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplicationTests.java b/src/test/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplicationTests.java
deleted file mode 100644
index d3742d7..0000000
--- a/src/test/java/de/unistuttgart/iste/meitrex/template/TemplateForMicroservicesApplicationTests.java
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.unistuttgart.iste.meitrex.template;
-
-import org.springframework.boot.test.context.SpringBootTest;
-
-@SpringBootTest
-class TemplateForMicroservicesApplicationTests {
-
-// @Test
-// void contextLoads() {
-// }
-
-}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index 7674264..e0a6c2b 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -2,4 +2,7 @@
spring.datasource.url=jdbc:tc:postgresql:///notification_service
spring.datasource.username=root
spring.datasource.password=root
-spring.jpa.hibernate.ddl-auto=create
\ No newline at end of file
+spring.jpa.hibernate.ddl-auto=create
+
+course_service.url=http://app-course:2001/graphql
+user_service.url=http://app-user:5001/graphql
\ No newline at end of file