diff --git a/appserver-jersey/Dockerfile b/appserver-jersey/Dockerfile new file mode 100644 index 000000000..b19847fc1 --- /dev/null +++ b/appserver-jersey/Dockerfile @@ -0,0 +1,42 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM --platform=$BUILDPLATFORM gradle:8.5-jdk17 as builder + +RUN mkdir /code +WORKDIR code +ENV GRADLE_USER_HOME=/code/.gradlecache \ + GRADLE_OPTS="-Djdk.lang.Process.launchMechanism=vfork -Dorg.gradle.vfs.watch=false" + +COPY ../buildSrc /code/buildSrc +COPY ../build.gradle.kts ../settings.gradle.kts /code/ +COPY ./build.gradle.kts /code/radar-appserver + +RUN gradle downloadDependencies copyDependencies + +COPY ./src /code/radar-appserver/src + +RUN gradle jar + +FROM eclipse-temurin:17-jre + +COPY --from=builder /code/radar-appserver/build/scripts/* /usr/bin/ +COPY --from=builder /code/radar-appserver/build/third-party/* /usr/lib/ +COPY --from=builder /code/radar-appserver/build/libs/*.jar /usr/lib/ + +USER 101 + +EXPOSE 8080 + +CMD ["radar-appserver"] + + diff --git a/appserver-jersey/build.gradle.kts b/appserver-jersey/build.gradle.kts index 8f6a7769d..d14f29ce4 100644 --- a/appserver-jersey/build.gradle.kts +++ b/appserver-jersey/build.gradle.kts @@ -1,11 +1,9 @@ plugins { application + kotlin("plugin.serialization") version Versions.kotlinVersion id("org.radarbase.radar-kotlin") version Versions.radarCommonsVersion kotlin("plugin.allopen") kotlin("plugin.noarg") - id("org.jetbrains.kotlin.plugin.spring") - id("org.jetbrains.kotlin.plugin.jpa") - } application { @@ -20,7 +18,6 @@ application { ) } - description = "RADAR Appserver for scheduling tasks and notifications." val integrationTestSourceSet = sourceSets.create("integrationTest") { @@ -51,11 +48,9 @@ allOpen { } dependencies { -// implementation(kotlin("stdlib-jdk8")) implementation(kotlin("reflect")) -// implementation("org.radarbase:radar-commons:${Versions.radarCommons}") -// implementation("org.radarbase:radar-commons-kotlin:${Versions.radarCommons}") + implementation("org.radarbase:radar-commons-kotlin:${Versions.radarCommonsVersion}") implementation("org.radarbase:radar-jersey:${Versions.radarJerseyVersion}") implementation("org.radarbase:radar-jersey-hibernate:${Versions.radarJerseyVersion}") { runtimeOnly("org.postgresql:postgresql:${Versions.postgresqlVersion}") @@ -76,27 +71,24 @@ dependencies { } } } + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("com.google.guava:guava:32.1.3-jre") implementation("org.quartz-scheduler:quartz:2.5.0") -// implementation("org.radarbase:managementportal-client:${Versions.radarAuth}") -// implementation("org.radarbase:lzfse-decode:${Versions.lzfse}") -// implementation("org.radarbase:radar-auth:${Versions.radarAuth}") + testImplementation("io.mockk:mockk:1.14.4") + testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0") + testImplementation("org.hamcrest:hamcrest:2.1") + testImplementation("org.assertj:assertj-core:3.24.2") -// implementation(platform("io.ktor:ktor-bom:${Versions.ktor}")) -// implementation("io.ktor:ktor-client-auth") - -// runtimeOnly("org.glassfish.grizzly:grizzly-framework-monitoring:${Versions.grizzly}") -// runtimeOnly("org.glassfish.grizzly:grizzly-http-monitoring:${Versions.grizzly}") -// runtimeOnly("org.glassfish.grizzly:grizzly-http-server-monitoring:${Versions.grizzly}") -// -// testImplementation("org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}") -// testImplementation("org.hamcrest:hamcrest:${Versions.hamcrest}") -// testImplementation("com.squareup.okhttp3:mockwebserver:${Versions.okHttp}") + integrationTestImplementation(platform("io.ktor:ktor-bom:${Versions.ktorVersion}")) + integrationTestImplementation("io.ktor:ktor-client-content-negotiation") + integrationTestImplementation("io.ktor:ktor-serialization-kotlinx-json") +} -// integrationTestImplementation(platform("io.ktor:ktor-bom:${Versions.ktor}")) -// integrationTestImplementation("io.ktor:ktor-client-content-negotiation") -// integrationTestImplementation("io.ktor:ktor-serialization-kotlinx-json") +ktlint { + ignoreFailures.set(false) + outputColorName.set("RED") } radarKotlin { diff --git a/appserver-jersey/docker-compose.yml b/appserver-jersey/docker-compose.yml new file mode 100644 index 000000000..87f3cd7bd --- /dev/null +++ b/appserver-jersey/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + #---------------------------------------------------------------------------# + # ManagementPortal Postgres # + #---------------------------------------------------------------------------# + managementportal-postgresql: + image: postgres + environment: + POSTGRES_USER: radarbase + POSTGRES_PASSWORD: radarbase + POSTGRES_DB: managementportal + + + #---------------------------------------------------------------------------# + # Management Portal # + #---------------------------------------------------------------------------# + managementportal: + image: radarbase/management-portal:2.1.0 + environment: + SERVER_PORT: 8081 + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:postgresql://managementportal-postgresql:5432/managementportal + SPRING_DATASOURCE_USERNAME: radarbase + SPRING_DATASOURCE_PASSWORD: radarbase + SPRING_LIQUIBASE_CONTEXTS: dev #includes testing_data, remove for production builds + JHIPSTER_SLEEP: 10 # gives time for the database to boot before the application + JAVA_OPTS: -Xmx512m # maximum heap size for the JVM running ManagementPortal, increase this as necessary + MANAGEMENTPORTAL_COMMON_BASE_URL: http://localhost:8081/managementportal + MANAGEMENTPORTAL_COMMON_MANAGEMENT_PORTAL_BASE_URL: http://localhost:8081/managementportal + MANAGEMENTPORTAL_FRONTEND_CLIENT_SECRET: + MANAGEMENTPORTAL_OAUTH_CLIENTS_FILE: /mp-includes/config/oauth_client_details.csv + volumes: + - ./src/integrationTest/resources/docker/etc/:/mp-includes/ + + #---------------------------------------------------------------------------# + # Appserver Postgres # + #---------------------------------------------------------------------------# + appserver-postgres: + image: postgres + environment: + POSTGRES_DB: appserver + POSTGRES_USER: radar + POSTGRES_PASSWORD: radar + + #---------------------------------------------------------------------------# + # Appserver # + #---------------------------------------------------------------------------# + appserver: + image: radarbase/radar-appserver + build: ./ + restart: always + ports: + - "8080:8080" + environment: + JDK_JAVA_OPTIONS: -Xmx4G -Djava.security.egd=file:/dev/./urandom + FCMSERVER_SENDERID: "1043784930865" + FCMSERVER_SERVERKEY: "AAAA8wZuFjE:APA91bGpJQ3Sft0mZAaVMjDJGNLjFsdDLTo-37ZN69r4lKlHiRN78t4bCfkNKcXG5c9cJg-eGtWB7FqceQUDVtf7B1Zvw_2ycynqwKe2YqXFeyaq83Gf0R3AS1EKSWFS60GCr8eUEliz" + APPSERVER_JDBC_URL: jdbc:postgresql://postgres:5432/radar diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/NotificationEndpointAuthTest.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/NotificationEndpointAuthTest.kt new file mode 100644 index 000000000..24b9e63c5 --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/NotificationEndpointAuthTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.radarbase.appserver.jersey.auth.commons.MpOAuthSupport +import org.radarbase.appserver.jersey.dto.ProjectDto +import org.radarbase.appserver.jersey.dto.fcm.FcmNotificationDto +import org.radarbase.appserver.jersey.dto.fcm.FcmNotifications +import org.radarbase.appserver.jersey.dto.fcm.FcmUserDto +import java.time.Duration +import java.time.Instant + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class NotificationEndpointAuthTest { + + val notification = FcmNotificationDto().apply { + scheduledTime = Instant.now().plus(Duration.ofSeconds(100)) + body = "Test Body" + sourceId = "test-source" + title = "Test Title" + ttlSeconds = 86400 + fcmMessageId = "123455" + additionalData = mutableMapOf() + appPackage = "armt" + sourceType = "armt" + type = "ESM" + } + + @BeforeEach + fun createUserAndProject(): Unit = runBlocking { + val project = ProjectDto(projectId = "radar") + + httpClient.post(PROJECT_PATH) { + contentType(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + setBody(project) + } + + val fcmUserDto = FcmUserDto( + projectId = "radar", + language = "en", + enrolmentDate = Instant.now(), + fcmToken = "xxx", + subjectId = "sub-1", + timezone = "Europe/London", + ) + + httpClient.post("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH") { + contentType(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + setBody(fcmUserDto) + } + } + + @Test + fun unAuthorizedViewNotificationsForUser(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/$DEFAULT_USER/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun unAuthorizedViewNotificationsForProject(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/$DEFAULT_PROJECT/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun unauthorizedCreateNotificationForUser(): Unit = runBlocking { + val response = httpClient.post( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/$DEFAULT_USER/$NOTIFICATION_PATH", + ) { + contentType(ContentType.Application.Json) + setBody(notification) + } + + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Order(1) + @Test + fun createNotificationForUser(): Unit = runBlocking { + val response = httpClient.post( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/$DEFAULT_USER/$NOTIFICATION_PATH", + ) { + setBody(notification) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + contentType(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.Created) + } + + @Order(2) + @Test + fun createBatchNotificationForUser(): Unit = runBlocking { + val singleNotification = notification + val notifications = FcmNotifications() + .withNotifications( + listOf( + singleNotification.apply { + title = "Test Title 1" + fcmMessageId = "xxxyyyy" + }, + ), + ) + + val response = httpClient.post( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/$DEFAULT_USER/$NOTIFICATION_PATH/batch", + ) { + setBody(notifications) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + contentType(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.Created) + } + + @Test + fun viewNotificationsForUser(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/$DEFAULT_USER/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(response.status, HttpStatusCode.OK) + } + + @Test + fun viewNotificationsForProject(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/$DEFAULT_PROJECT/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(response.status, HttpStatusCode.OK) + } + + @Test + fun forbiddenViewNotificationsForOtherUser(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/sub-2/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(response.status, HttpStatusCode.Forbidden) + } + + @Test + fun forbiddenViewNotificationsForOtherProject(): Unit = runBlocking { + val response = httpClient.get( + "$PROJECT_PATH/other-project/$NOTIFICATION_PATH", + ) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(response.status, HttpStatusCode.Forbidden) + } + + companion object { + private const val APPSERVER_URL = "http://localhost:8080" + private lateinit var AUTH_HEADERS: Headers + private lateinit var httpClient: HttpClient + private const val PROJECT_PATH = "projects" + private const val USER_PATH = "users" + private const val DEFAULT_PROJECT = "radar" + private const val DEFAULT_USER = "sub-1" + private const val NOTIFICATION_PATH = "messaging/notifications" + + @BeforeAll + @JvmStatic + fun init() { + httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true + }, + ) + } + defaultRequest { + url("${APPSERVER_URL}/") + } + } + + val oAuthSupport = MpOAuthSupport().apply { + init() + } + + AUTH_HEADERS = runBlocking { + headersOf( + HttpHeaders.Authorization, + "Bearer ${oAuthSupport.requestAccessToken()}", + ) + } + } + } +} diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/ProjectEndpointAuthTest.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/ProjectEndpointAuthTest.kt new file mode 100644 index 000000000..996c1da80 --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/ProjectEndpointAuthTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.radarbase.appserver.jersey.auth.commons.MpOAuthSupport +import org.radarbase.appserver.jersey.dto.ProjectDto + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class ProjectEndpointAuthTest { + + @Test + fun unAuthorizedCreatedProject(): Unit = runBlocking { + val project = ProjectDto(projectId = "radar") + val response = httpClient.post(PROJECT_PATH) { + contentType(ContentType.Application.Json) + setBody(project) + } + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun unAuthorizedViewProjects(): Unit = runBlocking { + val response = httpClient.get(PROJECT_PATH) { + accept(ContentType.Application.Json) + } + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun unAuthorizedViewSingleProject(): Unit = runBlocking { + val response = httpClient.get("$PROJECT_PATH/radar") { + accept(ContentType.Application.Json) + } + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun forbiddenViewProjects(): Unit = runBlocking { + val response = httpClient.get(PROJECT_PATH) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer ${AUTH_HEADERS[HttpHeaders.Authorization]}") + } + // Only Admins Can View List Of All Projects + assertEquals(response.status, HttpStatusCode.Forbidden) + } + + @Test + @Order(1) + fun createSingleProjectWithAuth() = runBlocking { + val project = ProjectDto(projectId = "radar") + val response = httpClient.post(PROJECT_PATH) { + contentType(ContentType.Application.Json) + setBody(project) + header(HttpHeaders.Authorization, "Bearer ${AUTH_HEADERS[HttpHeaders.Authorization]}") + } + + if (response.status == HttpStatusCode.ExpectationFailed) { + return@runBlocking + } + assertEquals(HttpStatusCode.Created, response.status) + } + + @Test + @Order(2) + fun getSingleProjectWithAuth() = runBlocking { + val response = httpClient.get("$PROJECT_PATH/radar") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer ${AUTH_HEADERS[HttpHeaders.Authorization]}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + @Order(3) + fun getForbiddenProjectWithAuth() = runBlocking { + val response = httpClient.get("$PROJECT_PATH/test") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, "Bearer ${AUTH_HEADERS[HttpHeaders.Authorization]}") + } + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + companion object { + private const val APPSERVER_URL = "http://localhost:8080" + private const val PROJECT_PATH = "projects" + private lateinit var AUTH_HEADERS: Headers + private lateinit var httpClient: HttpClient + + @BeforeAll + @JvmStatic + fun init() { + httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true + }, + ) + } + defaultRequest { + url("${APPSERVER_URL}/") + } + } + + val oAuthSupport = MpOAuthSupport().apply { + init() + } + + AUTH_HEADERS = runBlocking { + headersOf( + HttpHeaders.Authorization, + "Bearer ${oAuthSupport.requestAccessToken()}", + ) + } + } + } +} diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/UserEndpointAuthTest.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/UserEndpointAuthTest.kt new file mode 100644 index 000000000..7f0e03c6e --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/UserEndpointAuthTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.radarbase.appserver.jersey.auth.commons.MpOAuthSupport +import org.radarbase.appserver.jersey.dto.ProjectDto +import org.radarbase.appserver.jersey.dto.fcm.FcmUserDto +import java.time.Instant + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class UserEndpointAuthTest { + val fcmUserDto = FcmUserDto( + projectId = "radar", + language = "en", + enrolmentDate = Instant.now(), + fcmToken = "xxx", + subjectId = "sub-1", + timezone = "Europe/London", + ) + + @BeforeEach + fun createProject(): Unit = runBlocking { + val project = ProjectDto(projectId = DEFAULT_PROJECT) + + httpClient.post { + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + contentType(ContentType.Application.Json) + setBody(project) + } + } + + @Test + fun unauthorizedViewSingleUser(): Unit = runBlocking { + val response = httpClient.get("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/sub-1") { + accept(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + fun unAuthorizedCreateUser(): Unit = runBlocking { + val response = httpClient.post("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH") { + contentType(ContentType.Application.Json) + setBody(fcmUserDto) + } + + assertEquals(response.status, HttpStatusCode.Unauthorized) + } + + @Test + @Order(1) + fun createUser(): Unit = runBlocking { + val response = httpClient.post("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH") { + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + contentType(ContentType.Application.Json) + setBody(fcmUserDto) + } + + if (response.status == HttpStatusCode.ExpectationFailed) { + // The auth was successful but expectation failed if the user already exits. + // Since this is just an auth test we can return. + return@runBlocking + } + + assertEquals(response.status, HttpStatusCode.Created) + } + + @Test + @Order(2) + fun viewUser(): Unit = runBlocking { + val response = httpClient.get("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH/sub-1") { + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + accept(ContentType.Application.Json) + } + + assertEquals(response.status, HttpStatusCode.OK) + } + + @Test + @Order(3) + fun viewUsersInProject(): Unit = runBlocking { + val response = httpClient.get("$PROJECT_PATH/$DEFAULT_PROJECT/$USER_PATH") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + @Order(4) + fun forbiddenViewUsersInOtherProject(): Unit = runBlocking { + val response = httpClient.get("$PROJECT_PATH/other-project/$USER_PATH") { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + assertEquals(response.status, HttpStatusCode.Forbidden) + } + + @Test + @Order(5) + fun viewAllUsers() = runBlocking { + val response = httpClient.get(USER_PATH) { + accept(ContentType.Application.Json) + header(HttpHeaders.Authorization, AUTH_HEADERS[HttpHeaders.Authorization]) + } + + // Should return a filtered list of users for which the token has access. + assertEquals(response.status, HttpStatusCode.OK) + } + + companion object { + private const val APPSERVER_URL = "http://localhost:8080" + private const val PROJECT_PATH = "projects" + private const val USER_PATH = "users" + private const val DEFAULT_PROJECT = "radar" + private lateinit var AUTH_HEADERS: Headers + private lateinit var httpClient: HttpClient + + @BeforeAll + @JvmStatic + fun init() { + httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true + }, + ) + } + defaultRequest { + url("${APPSERVER_URL}/") + } + } + + val oAuthSupport = MpOAuthSupport().apply { + init() + } + + AUTH_HEADERS = runBlocking { + headersOf( + HttpHeaders.Authorization, + "Bearer ${oAuthSupport.requestAccessToken()}", + ) + } + } + } +} diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPMetaToken.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPMetaToken.kt new file mode 100644 index 000000000..43fc77294 --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPMetaToken.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth.commons + +import kotlinx.serialization.Serializable + +@Serializable +data class MPMetaToken( + val refreshToken: String, +) diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPPairResponse.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPPairResponse.kt new file mode 100644 index 000000000..3988e8358 --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MPPairResponse.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth.commons + +import kotlinx.serialization.Serializable + +@Serializable +class MPPairResponse( + val tokenUrl: String, +) diff --git a/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MpOAuthSupport.kt b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MpOAuthSupport.kt new file mode 100644 index 000000000..c93741a9d --- /dev/null +++ b/appserver-jersey/src/integrationTest/kotlin/org/radarbase/appserver/jersey/auth/commons/MpOAuthSupport.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.auth.commons + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.basicAuth +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.http.HttpStatusCode +import io.ktor.http.Parameters +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.radarbase.ktor.auth.OAuth2AccessToken +import org.radarbase.ktor.auth.bearer + +class MpOAuthSupport { + private lateinit var httpClient: HttpClient + + fun init() { + httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true + }, + ) + } + defaultRequest { + url("${MANAGEMENTPORTAL_URL}/") + } + } + } + + suspend fun requestAccessToken(): String { + val response = httpClient.submitForm( + url = "oauth/token", + formParameters = Parameters.build { + append("username", ADMIN_USER) + append("password", ADMIN_PASSWORD) + append("grant_type", "password") + }, + ) { + basicAuth(username = MP_CLIENT, password = "") + } + assertThat(response.status, equalTo(HttpStatusCode.OK)) + val token = response.body() + + val tokenUrl = httpClient.get("api/oauth-clients/pair") { + url { + parameters.append("clientId", REST_CLIENT) + parameters.append("login", "sub-1") + parameters.append("persistent", "false") + } + bearer(requireNotNull(token.accessToken)) + }.body().tokenUrl + + println("Requesting refresh token") + val refreshToken = httpClient.get(tokenUrl).body().refreshToken + + return requireNotNull( + httpClient.submitForm( + url = "oauth/token", + formParameters = Parameters.build { + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + }, + ) { + basicAuth(REST_CLIENT, "") + }.body().accessToken, + ) + } + + companion object { + private const val MANAGEMENTPORTAL_URL = "http://localhost:8081/managementportal" + const val MP_CLIENT = "ManagementPortalapp" + const val REST_CLIENT = "pRMT" + const val ADMIN_USER = "admin" + const val ADMIN_PASSWORD = "admin" + } +} diff --git a/appserver-jersey/src/integrationTest/resources/docker/docker-compose.yml b/appserver-jersey/src/integrationTest/resources/docker/docker-compose.yml new file mode 100644 index 000000000..b265a210e --- /dev/null +++ b/appserver-jersey/src/integrationTest/resources/docker/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + #---------------------------------------------------------------------------# + # ManagementPortal Postgres # + #---------------------------------------------------------------------------# + managementportal-postgresql: + image: postgres + environment: + POSTGRES_USER: radarbase + POSTGRES_PASSWORD: radarbase + POSTGRES_DB: managementportal + + + #---------------------------------------------------------------------------# + # Management Portal # + #---------------------------------------------------------------------------# + managementportal: + image: radarbase/management-portal:2.1.0 + environment: + SERVER_PORT: 8081 + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:postgresql://managementportal-postgresql:5432/managementportal + SPRING_DATASOURCE_USERNAME: radarbase + SPRING_DATASOURCE_PASSWORD: radarbase + SPRING_LIQUIBASE_CONTEXTS: dev #includes testing_data, remove for production builds + JHIPSTER_SLEEP: 10 # gives time for the database to boot before the application + JAVA_OPTS: -Xmx512m # maximum heap size for the JVM running ManagementPortal, increase this as necessary + MANAGEMENTPORTAL_COMMON_BASE_URL: http://localhost:8081/managementportal + MANAGEMENTPORTAL_COMMON_MANAGEMENT_PORTAL_BASE_URL: http://localhost:8081/managementportal + MANAGEMENTPORTAL_FRONTEND_CLIENT_SECRET: + MANAGEMENTPORTAL_OAUTH_CLIENTS_FILE: /mp-includes/config/oauth_client_details.csv + volumes: + - ./etc/:/mp-includes/ + + #---------------------------------------------------------------------------# + # Appserver Postgres # + #---------------------------------------------------------------------------# + appserver-postgres: + image: postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: radar + POSTGRES_USER: radar + POSTGRES_PASSWORD: radar diff --git a/appserver-jersey/src/integrationTest/resources/docker/etc.config/keystore.p12 b/appserver-jersey/src/integrationTest/resources/docker/etc.config/keystore.p12 new file mode 100644 index 000000000..f1cdeb82a Binary files /dev/null and b/appserver-jersey/src/integrationTest/resources/docker/etc.config/keystore.p12 differ diff --git a/appserver-jersey/src/integrationTest/resources/docker/etc.config/oauth_client_details.csv b/appserver-jersey/src/integrationTest/resources/docker/etc.config/oauth_client_details.csv new file mode 100644 index 000000000..79e5149e3 --- /dev/null +++ b/appserver-jersey/src/integrationTest/resources/docker/etc.config/oauth_client_details.csv @@ -0,0 +1,8 @@ +client_id;resource_ids;client_secret;scope;authorized_grant_types;redirect_uri;authorities;access_token_validity;refresh_token_validity;additional_information;autoapprove +pRMT;res_ManagementPortal,res_gateway,res_AppServer;;MEASUREMENT.CREATE,SUBJECT.UPDATE,SUBJECT.READ,PROJECT.READ,SOURCETYPE.READ,SOURCE.READ,SOURCETYPE.READ,SOURCEDATA.READ,USER.READ,ROLE.READ;refresh_token,authorization_code;;;43200;7948800;{"dynamic_registration": true}; +aRMT;res_ManagementPortal,res_gateway,res_AppServer;;MEASUREMENT.CREATE,SUBJECT.UPDATE,SUBJECT.READ,PROJECT.READ,SOURCETYPE.READ,SOURCE.READ,SOURCETYPE.READ,SOURCEDATA.READ,USER.READ,ROLE.READ;refresh_token,authorization_code;;;43200;7948800;{"dynamic_registration": true}; +THINC-IT;res_ManagementPortal,res_gateway;secret;MEASUREMENT.CREATE,SUBJECT.UPDATE,SUBJECT.READ,PROJECT.READ,SOURCETYPE.READ,SOURCE.READ,SOURCETYPE.READ,SOURCEDATA.READ,USER.READ,ROLE.READ;refresh_token,authorization_code;;;43200;7948800;{"dynamic_registration": true}; +radar_restapi;res_ManagementPortal;secret;SUBJECT.READ,PROJECT.READ,SOURCE.READ,SOURCETYPE.READ;password,client_credentials;;;43200;259200;{}; +radar_redcap_integrator;res_ManagementPortal;secret;PROJECT.READ,SUBJECT.CREATE,SUBJECT.READ,SUBJECT.UPDATE;client_credentials;;;43200;259200;{}; +radar_dashboard;res_ManagementPortal,res_RestApi;secret;SUBJECT.READ,PROJECT.READ,SOURCE.READ,SOURCETYPE.READ,MEASUREMENT.READ;client_credentials;;;43200;259200;{}; +radar_appserver_client;res_ManagementPortal,res_AppServer;;MEASUREMENT.CREATE,SUBJECT.UPDATE,SUBJECT.READ,PROJECT.READ,SOURCETYPE.READ,SOURCE.READ,SOURCETYPE.READ,SOURCEDATA.READ,USER.READ,ROLE.READ;client_credentials;;;43200;259200;{}; \ No newline at end of file diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/application/event/EventBusStartupListener.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/application/event/EventBusStartupListener.kt new file mode 100644 index 000000000..84e04d91f --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/application/event/EventBusStartupListener.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.application.event + +import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import org.glassfish.hk2.api.ServiceLocator +import org.glassfish.jersey.server.monitoring.ApplicationEvent +import org.glassfish.jersey.server.monitoring.ApplicationEventListener +import org.glassfish.jersey.server.monitoring.RequestEvent +import org.glassfish.jersey.server.monitoring.RequestEventListener +import org.radarbase.appserver.jersey.event.listener.MessageStateEventListener +import org.radarbase.appserver.jersey.event.listener.TaskStateEventListener + +class EventBusStartupListener @Inject constructor( + private val eventBus: EventBus, + private val serviceLocator: ServiceLocator, +) : ApplicationEventListener { + + override fun onEvent(event: ApplicationEvent) { + if (event.type == ApplicationEvent.Type.INITIALIZATION_FINISHED) { + val taskListener = serviceLocator.getService(TaskStateEventListener::class.java) + val messageListener = serviceLocator.getService(MessageStateEventListener::class.java) + + eventBus.register(taskListener) + eventBus.register(messageListener) + } + } + + override fun onRequest(requestEvent: RequestEvent?): RequestEventListener? { + return null + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/AuthConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/AuthConfig.kt index f3e30fd95..2d30f8108 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/AuthConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/AuthConfig.kt @@ -19,11 +19,11 @@ package org.radarbase.appserver.jersey.config data class AuthConfig( val resourceName: String = "res_Appserver", val issuer: String? = null, - val managementPortalUrl: String? = null, + val managementPortalUrl: String = "http://localhost:8081/managementportal", val publicKeyUrls: List? = null, ) : Validation { override fun validate() { - check(managementPortalUrl != null || !publicKeyUrls.isNullOrEmpty()) { + check(managementPortalUrl.isBlank() || publicKeyUrls.isNullOrEmpty()) { "At least one of auth.publicKeyUrls or auth.managementPortalUrl must be configured" } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/DbConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/DbConfig.kt index 6ea656c1f..4357a7321 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/DbConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/DbConfig.kt @@ -16,7 +16,13 @@ package org.radarbase.appserver.jersey.config +import org.radarbase.appserver.jersey.entity.DataMessage +import org.radarbase.appserver.jersey.entity.DataMessageStateEvent +import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.entity.NotificationStateEvent import org.radarbase.appserver.jersey.entity.Project +import org.radarbase.appserver.jersey.entity.Task +import org.radarbase.appserver.jersey.entity.TaskStateEvent import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.entity.UserMetrics import org.radarbase.appserver.jersey.utils.checkInvalidDetails @@ -26,11 +32,17 @@ data class DbConfig( Project::class.qualifiedName!!, User::class.qualifiedName!!, UserMetrics::class.qualifiedName!!, + Task::class.qualifiedName!!, + Notification::class.qualifiedName!!, + DataMessage::class.qualifiedName!!, + TaskStateEvent::class.qualifiedName!!, + NotificationStateEvent::class.qualifiedName!!, + DataMessageStateEvent::class.qualifiedName!!, ), - val jdbcDriver: String? = null, - val jdbcUrl: String? = null, - val username: String? = null, - val password: String? = null, + val jdbcDriver: String = "org.postgresql.Driver", + val jdbcUrl: String = "jdbc:postgresql://localhost:5432/appserver", + val username: String = "radar", + val password: String = "radar", val hibernateDialect: String = "org.hibernate.dialect.PostgreSQLDialect", val additionalProperties: Map = emptyMap(), val liquibase: LiquibaseConfig = LiquibaseConfig(), @@ -38,7 +50,7 @@ data class DbConfig( override fun validate() { checkInvalidDetails( { - jdbcDriver.isNullOrBlank() || jdbcUrl.isNullOrBlank() + jdbcDriver.isBlank() || jdbcUrl.isBlank() }, { "JDBC driver and URL must not be null or empty" diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/EmailConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/EmailConfig.kt index 4b06d7a23..411ff4765 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/EmailConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/EmailConfig.kt @@ -16,6 +16,7 @@ package org.radarbase.appserver.jersey.config +// TODO: The email work needs to be done, not sure should we use smtp server here in jersey? data class EmailConfig( - val enabled: Boolean? = null, + val enabled: Boolean = false, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/FcmServerConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/FcmServerConfig.kt index 2ccb0305b..e83696871 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/FcmServerConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/FcmServerConfig.kt @@ -17,6 +17,6 @@ package org.radarbase.appserver.jersey.config data class FcmServerConfig( - val fcmsender: String? = null, - val credentials: String? = null + val fcmsender: String? = "org.radarbase.appserver.jersey.fcm.downstream.AdminSdkFcmSender", + val credentials: String? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/LiquibaseConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/LiquibaseConfig.kt index f23d1e04c..c68999268 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/LiquibaseConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/LiquibaseConfig.kt @@ -16,6 +16,6 @@ package org.radarbase.appserver.jersey.config -data class LiquibaseConfig ( +data class LiquibaseConfig( val enabled: Boolean = false, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/SchedulerConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/SchedulerConfig.kt index b25311f42..f60fe9c03 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/SchedulerConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/SchedulerConfig.kt @@ -16,5 +16,7 @@ package org.radarbase.appserver.jersey.config -class SchedulerConfig { -} +data class SchedulerConfig( + val coroutineDispatcher: String = "io", + val coroutineJob: String = "supervisor-job", +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/ServerConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/ServerConfig.kt index 906fbec8a..56719af26 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/ServerConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/ServerConfig.kt @@ -21,6 +21,15 @@ import java.net.URI data class ServerConfig( /** Base URL to serve data with. This will determine the base path and the port. */ val baseUri: URI = URI.create("http://0.0.0.0:8090/kafka/"), + /** + * Maximum time in seconds to wait for a request to complete. + * This timeout is applied to the co-routine context, not to the Grizzly server. + */ + val requestTimeout: Int = 30, + /** + * Whether JMX should be enabled. Disable if not needed, for higher performance. + */ + val isJmxEnabled: Boolean = false, ) : Validation { override fun validate() { check(baseUri.toString().isNotBlank()) { "Base URL must not be blank." } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubCacheConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubCacheConfig.kt index ec1dce9d0..28484ec10 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubCacheConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubCacheConfig.kt @@ -20,8 +20,8 @@ import com.fasterxml.jackson.annotation.JsonProperty data class GithubCacheConfig( @field:JsonProperty("cacheDurationSec") - val cacheDuration: Long? = null, + val cacheDuration: Long = 3600, @field:JsonProperty("retryDurationSec") - val retryDuration: Long? = null, + val retryDuration: Long = 60, val maxCacheSize: Int = 10000, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubClientConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubClientConfig.kt index 3742e7675..5fafb58d8 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubClientConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubClientConfig.kt @@ -20,7 +20,7 @@ import com.fasterxml.jackson.annotation.JsonProperty data class GithubClientConfig( val maxContentLength: Long = 10_00_000, - @JsonProperty("timeoutSec") + @field:JsonProperty("timeoutSec") val timeout: Long = 10L, val githubToken: String? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubConfig.kt index cd36febf9..a857b2acb 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/github/GithubConfig.kt @@ -18,5 +18,5 @@ package org.radarbase.appserver.jersey.config.github data class GithubConfig( val cache: GithubCacheConfig = GithubCacheConfig(), - val client: GithubClientConfig = GithubClientConfig() + val client: GithubClientConfig = GithubClientConfig(), ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/questionnaire/QuestionnaireProtocolConfig.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/questionnaire/QuestionnaireProtocolConfig.kt index 089964564..1cfa8c9b2 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/questionnaire/QuestionnaireProtocolConfig.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/config/questionnaire/QuestionnaireProtocolConfig.kt @@ -17,7 +17,7 @@ package org.radarbase.appserver.jersey.config.questionnaire data class QuestionnaireProtocolConfig( - val githubProtocolRepo: String? = null, - val protocolFileName: String? = null, - val githubBranch: String? = null, + val githubProtocolRepo: String = "RADAR-base/RADAR-aRMT-protocols", + val protocolFileName: String = "protocol.json", + val githubBranch: String = "master", ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/DataMessageStateEventDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/DataMessageStateEventDto.kt new file mode 100644 index 000000000..45683b847 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/DataMessageStateEventDto.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.radarbase.appserver.jersey.event.state.MessageState +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class DataMessageStateEventDto( + var id: Long? = null, + var dataMessageId: Long? = null, + var state: MessageState? = null, + var time: Instant? = null, + var associatedInfo: String? = null, +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/NotificationStateEventDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/NotificationStateEventDto.kt new file mode 100644 index 000000000..e3b86d7cb --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/NotificationStateEventDto.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.radarbase.appserver.jersey.event.state.MessageState +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class NotificationStateEventDto( + var id: Long? = null, + var notificationId: Long? = null, + var state: MessageState? = null, + var time: Instant? = null, + var associatedInfo: String? = null, +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/ProjectDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/ProjectDto.kt index 15cd808dc..22efbda76 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/ProjectDto.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/ProjectDto.kt @@ -39,16 +39,16 @@ data class ProjectDto( var projectId: String? = null, @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var createdAt: Instant? = null, @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var updatedAt: Instant? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/TaskStateEventDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/TaskStateEventDto.kt new file mode 100644 index 000000000..276509147 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/TaskStateEventDto.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import org.radarbase.appserver.jersey.event.state.TaskState +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class TaskStateEventDto( + var id: Long? = null, + var taskId: Long? = null, + var state: TaskState? = null, + var time: Instant? = null, + var associatedInfo: String? = null, +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessageDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessageDto.kt index 1de09cb56..cfbd66592 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessageDto.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessageDto.kt @@ -28,9 +28,9 @@ class FcmDataMessageDto(dataMessageEntity: DataMessage? = null) { var id: Long? = dataMessageEntity?.id @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var scheduledTime: @NotNull Instant? = dataMessageEntity?.scheduledTime @@ -62,91 +62,35 @@ class FcmDataMessageDto(dataMessageEntity: DataMessage? = null) { var mutableContent: Boolean = dataMessageEntity?.mutableContent == true @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) - var createdAt: Instant? = dataMessageEntity?.createdAt + var createdAt: Instant? = dataMessageEntity?.createdAt?.toInstant() @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) - var updatedAt: Instant? = dataMessageEntity?.updatedAt - - fun withCreatedAt(createdAt: Instant?): FcmDataMessageDto = apply { - this.createdAt = createdAt - } - - fun withUpdatedAt(updatedAt: Instant?): FcmDataMessageDto = apply { - this.updatedAt = updatedAt - } - - fun withId(id: Long?): FcmDataMessageDto = apply { - this.id = id - } - - fun withScheduledTime(scheduledTime: Instant?): FcmDataMessageDto = apply { - this.scheduledTime = scheduledTime - } - - fun withDelivered(delivered: Boolean): FcmDataMessageDto = apply { - this.delivered = delivered - } - - fun withTtlSeconds(ttlSeconds: Int): FcmDataMessageDto = apply { - this.ttlSeconds = ttlSeconds - } - - fun withFcmMessageId(fcmMessageId: String?): FcmDataMessageDto = apply { - this.fcmMessageId = fcmMessageId - } - - fun withSourceId(sourceId: String?): FcmDataMessageDto = apply { - this.sourceId = sourceId - } - - fun withAppPackage(appPackage: String?): FcmDataMessageDto = apply { - this.appPackage = appPackage - } - - fun withSourceType(sourceType: String?): FcmDataMessageDto = apply { - this.sourceType = sourceType - } - - fun withDataMap(dataMap: MutableMap?): FcmDataMessageDto = apply { - this.dataMap = dataMap - } - - fun withFcmTopic(fcmTopic: String?): FcmDataMessageDto = apply { - this.fcmTopic = fcmTopic - } - - fun withFcmCondition(fcmCondition: String?): FcmDataMessageDto = apply { - this.fcmCondition = fcmCondition - } - - fun withPriority(priority: String?): FcmDataMessageDto = apply { - this.priority = priority - } - - fun withMutableContent(mutableContent: Boolean): FcmDataMessageDto = apply { - this.mutableContent = mutableContent - } + var updatedAt: Instant? = dataMessageEntity?.updatedAt?.toInstant() override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is FcmDataMessageDto) return false val that = other - return delivered == that.delivered && ttlSeconds == that.ttlSeconds && scheduledTime == that.scheduledTime - && appPackage == that.appPackage - && sourceType == that.sourceType + return delivered == that.delivered && ttlSeconds == that.ttlSeconds && scheduledTime == that.scheduledTime && + appPackage == that.appPackage && + sourceType == that.sourceType } override fun hashCode(): Int { return Objects.hash( - scheduledTime, delivered, ttlSeconds, appPackage, sourceType + scheduledTime, + delivered, + ttlSeconds, + appPackage, + sourceType, ) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessages.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessages.kt index 372f7c3fb..a42f409ef 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessages.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmDataMessages.kt @@ -22,19 +22,14 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.Objects -class FcmDataMessages { - +class FcmDataMessages( @field:Size(max = 200) - private var _dataMessages: MutableList = mutableListOf() + private val _dataMessages: MutableList, +) { val dataMessages: List get() = _dataMessages - fun withDataMessages(dataMessages: List): FcmDataMessages { - this._dataMessages = dataMessages.toMutableList() - return this - } - fun addDataMessage(dataMessageDto: FcmDataMessageDto): FcmDataMessages { if (!_dataMessages.contains(dataMessageDto)) { this._dataMessages.add(dataMessageDto) @@ -53,7 +48,6 @@ class FcmDataMessages { return Objects.hash(_dataMessages) } - companion object { private val logger: Logger = LoggerFactory.getLogger(FcmDataMessages::class.java) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotificationDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotificationDto.kt index cfd5fd7f7..8cfd353dc 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotificationDto.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotificationDto.kt @@ -30,9 +30,9 @@ class FcmNotificationDto(notificationEntity: Notification? = null) { @field:NotNull @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var scheduledTime: Instant? = notificationEntity?.scheduledTime @@ -108,150 +108,18 @@ class FcmNotificationDto(notificationEntity: Notification? = null) { var mutableContent: Boolean = notificationEntity?.mutableContent == true @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) - var createdAt: Instant? = notificationEntity?.createdAt + var createdAt: Instant? = notificationEntity?.createdAt?.toInstant() @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) - var updatedAt: Instant? = notificationEntity?.updatedAt - - fun withCreatedAt(createdAt: Instant?): FcmNotificationDto = apply { - this.createdAt = createdAt - } - - fun withUpdatedAt(updatedAt: Instant?): FcmNotificationDto = apply { - this.updatedAt = updatedAt - } - - fun withId(id: Long?): FcmNotificationDto = apply { - this.id = id - } - - fun withScheduledTime(scheduledTime: Instant?): FcmNotificationDto = apply { - this.scheduledTime = scheduledTime - } - - fun withDelivered(delivered: Boolean): FcmNotificationDto = apply { - this.delivered = delivered - } - - fun withTitle(title: String?): FcmNotificationDto = apply { - this.title = title - } - - fun withBody(body: String?): FcmNotificationDto = apply { - this.body = body - } - - fun withTtlSeconds(ttlSeconds: Int): FcmNotificationDto = apply { - this.ttlSeconds = ttlSeconds - } - - fun withFcmMessageId(fcmMessageId: String?): FcmNotificationDto = apply { - this.fcmMessageId = fcmMessageId - } - - fun withSourceId(sourceId: String?): FcmNotificationDto = apply { - this.sourceId = sourceId - } - - fun withType(type: String?): FcmNotificationDto = apply { - this.type = type - } - - fun withAppPackage(appPackage: String?): FcmNotificationDto = apply { - this.appPackage = appPackage - } - - fun withSourceType(sourceType: String?): FcmNotificationDto = apply { - this.sourceType = sourceType - } - - fun withAdditionalData(additionalData: MutableMap?): FcmNotificationDto = apply { - this.additionalData = additionalData - } - - fun withFcmTopic(fcmTopic: String?): FcmNotificationDto = apply { - this.fcmTopic = fcmTopic - } - - fun withFcmCondition(fcmCondition: String?): FcmNotificationDto = apply { - this.fcmCondition = fcmCondition - } - - fun withPriority(priority: String?): FcmNotificationDto = apply { - this.priority = priority - } - - fun withSound(sound: String?): FcmNotificationDto = apply { - this.sound = sound - } - - fun withBadge(badge: String?): FcmNotificationDto = apply { - this.badge = badge - } - - fun withSubtitle(subtitle: String?): FcmNotificationDto = apply { - this.subtitle = subtitle - } - - fun withIcon(icon: String?): FcmNotificationDto = apply { - this.icon = icon - } - - fun withColor(color: String?): FcmNotificationDto = apply { - this.color = color - } - - fun withBodyLocKey(bodyLocKey: String?): FcmNotificationDto = apply { - this.bodyLocKey = bodyLocKey - } - - fun withBodyLocArgs(bodyLocArgs: String?): FcmNotificationDto = apply { - this.bodyLocArgs = bodyLocArgs - } - - fun withTitleLocKey(titleLocKey: String?): FcmNotificationDto = apply { - this.titleLocKey = titleLocKey - } - - fun withTitleLocArgs(titleLocArgs: String?): FcmNotificationDto = apply { - this.titleLocArgs = titleLocArgs - } - - fun withAndroidChannelId(androidChannelId: String?): FcmNotificationDto = apply { - this.androidChannelId = androidChannelId - } - - fun withTag(tag: String?): FcmNotificationDto = apply { - this.tag = tag - } - - fun withClickAction(clickAction: String?): FcmNotificationDto = apply { - this.clickAction = clickAction - } - - fun withEmailEnabled(emailEnabled: Boolean): FcmNotificationDto = apply { - this.emailEnabled = emailEnabled - } - - fun withEmailTitle(emailTitle: String?): FcmNotificationDto = apply { - this.emailTitle = emailTitle - } - - fun withEmailBody(emailBody: String?): FcmNotificationDto = apply { - this.emailBody = emailBody - } - - fun withMutableContent(mutableContent: Boolean): FcmNotificationDto = apply { - this.mutableContent = mutableContent - } + var updatedAt: Instant? = notificationEntity?.updatedAt?.toInstant() override fun equals(other: Any?): Boolean = equalTo( other, @@ -265,10 +133,16 @@ class FcmNotificationDto(notificationEntity: Notification? = null) { FcmNotificationDto::sourceType, ) - override fun hashCode(): Int { return Objects.hash( - scheduledTime, delivered, title, body, ttlSeconds, type, appPackage, sourceType + scheduledTime, + delivered, + title, + body, + ttlSeconds, + type, + appPackage, + sourceType, ) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotifications.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotifications.kt index 1f738953d..1c1e3fa64 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotifications.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmNotifications.kt @@ -23,17 +23,13 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.Objects -class FcmNotifications { - +class FcmNotifications( @field:Size(max = 200) - private var _notifications: MutableList = mutableListOf() + private val _notifications: MutableList, +) { val notifications: List - get() = _notifications - - fun withNotifications(notifications: List): FcmNotifications = apply { - this._notifications = notifications.toMutableList() - } + get() = _notifications.toMutableList() fun addNotification(notificationDto: FcmNotificationDto): FcmNotifications = apply { if (!_notifications.contains(notificationDto)) { @@ -56,7 +52,6 @@ class FcmNotifications { return Objects.hash(_notifications) } - companion object { private val logger: Logger = LoggerFactory.getLogger(FcmNotifications::class.java) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUserDto.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUserDto.kt index df5a8138e..aad284158 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUserDto.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUserDto.kt @@ -56,24 +56,24 @@ data class FcmUserDto( var lastDelivered: Instant? = null, @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var createdAt: Instant? = null, @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var updatedAt: Instant? = null, @field:NotNull @field:JsonFormat( - shape = JsonFormat.Shape.STRING, + shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", - timezone = "UTC" + timezone = "UTC", ) var enrolmentDate: Instant? = null, @@ -97,11 +97,11 @@ data class FcmUserDto( email = user.emailAddress, lastOpened = user.usermetrics?.lastOpened, lastDelivered = user.usermetrics?.lastDelivered, - createdAt = user.createdAt, - updatedAt = user.updatedAt, + createdAt = user.createdAt?.toInstant(), + updatedAt = user.updatedAt?.toInstant(), enrolmentDate = user.enrolmentDate, timezone = user.timezone, fcmToken = user.fcmToken, language = user.language, - attributes = user.attributes - )} + attributes = user.attributes, + ) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUsers.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUsers.kt index e9d3e5b1c..c9e93bd72 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUsers.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/fcm/FcmUsers.kt @@ -20,5 +20,5 @@ import jakarta.validation.constraints.Size data class FcmUsers( @field:Size(max = 1500) - var users: List, + val users: List, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Assessment.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Assessment.kt index 34910bdec..996730202 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Assessment.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Assessment.kt @@ -17,6 +17,7 @@ package org.radarbase.appserver.jersey.dto.protocol import jakarta.persistence.Column +import kotlinx.serialization.Serializable /** * Data Transfer object (DTO) for Assessment. A project may represent a Protocol for scheduling @@ -26,6 +27,7 @@ import jakarta.persistence.Column * @see Protocol */ +@Serializable data class Assessment( var name: String? = null, private var _type: AssessmentType? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentProtocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentProtocol.kt index dc2fa6d43..5c312f1f4 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentProtocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentProtocol.kt @@ -16,22 +16,17 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import org.radarbase.appserver.jersey.utils.deserializer.ReferenceTimestampDeserializer +import kotlinx.serialization.Serializable +import org.radarbase.appserver.jersey.serialization.ReferenceTimestampSerializer +@Serializable data class AssessmentProtocol( var repeatProtocol: RepeatProtocol? = null, var reminders: ReminderTimePeriod? = null, var completionWindow: TimePeriod? = null, var repeatQuestionnaire: RepeatQuestionnaire? = null, + @Serializable(with = ReferenceTimestampSerializer::class) var referenceTimestamp: ReferenceTimestamp? = null, var clinicalProtocol: ClinicalProtocol? = null, var notification: NotificationProtocol = NotificationProtocol(), -) { - @JsonDeserialize(using = ReferenceTimestampDeserializer::class) - fun setReferenceTimestamp(responseObject: Any?) { - if (responseObject is ReferenceTimestamp) { - this.referenceTimestamp = responseObject - } - } -} +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentType.kt index c28043992..c8800312a 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/AssessmentType.kt @@ -16,18 +16,20 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable enum class AssessmentType { - @JsonProperty("scheduled") + @SerialName("scheduled") SCHEDULED, - @JsonProperty("clinical") + @SerialName("clinical") CLINICAL, - @JsonProperty("triggered") + @SerialName("triggered") TRIGGERED, - @JsonProperty("all") + @SerialName("all") ALL, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ClinicalProtocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ClinicalProtocol.kt index bc801f1e2..ae45e64a0 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ClinicalProtocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ClinicalProtocol.kt @@ -16,6 +16,9 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable + +@Serializable data class ClinicalProtocol( var requiresInClinicCompletion: Boolean = false, var repeatAfterClinicVisit: RepeatQuestionnaire? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/DefinitionInfo.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/DefinitionInfo.kt index d046d1d07..7aee2b4d8 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/DefinitionInfo.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/DefinitionInfo.kt @@ -16,9 +16,13 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable +import org.radarbase.appserver.jersey.serialization.URISerializer import java.net.URI +@Serializable data class DefinitionInfo( + @Serializable(with = URISerializer::class) val repository: URI? = null, val name: String? = null, val avsc: String? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/EmailNotificationProtocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/EmailNotificationProtocol.kt index 2f949a7bd..e15d08527 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/EmailNotificationProtocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/EmailNotificationProtocol.kt @@ -16,15 +16,17 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class EmailNotificationProtocol( - @field:JsonProperty("enabled") + @SerialName("enabled") val enabled: Boolean = false, - @field:JsonProperty("title") + @SerialName("title") var title: LanguageText? = null, - @field:JsonProperty("text") + @SerialName("text") var body: LanguageText? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/GithubContent.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/GithubContent.kt index 590c5ab26..88490320d 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/GithubContent.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/GithubContent.kt @@ -16,20 +16,16 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import org.radarbase.appserver.jersey.utils.deserializer.Base64Deserializer +import kotlinx.serialization.Serializable +import org.radarbase.appserver.jersey.serialization.Base64AsStringSerializer +@Serializable data class GithubContent( - @field:JsonDeserialize(using = Base64Deserializer::class) + @Serializable(with = Base64AsStringSerializer::class) var content: String? = null, - var sha: String? = null, - var size: String? = null, - var url: String? = null, - var node_id: String? = null, - var encoding: String? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/LanguageText.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/LanguageText.kt index 47e4f30cd..c719a1f6b 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/LanguageText.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/LanguageText.kt @@ -16,11 +16,13 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable import java.util.Locale /** * Data Transfer object (DTO) for LanguageText. Handles multi-language support for text. */ +@Serializable data class LanguageText( var en: String? = null, var it: String? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocol.kt index 336d7762e..14a3e8e3c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocol.kt @@ -16,15 +16,17 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class NotificationProtocol( - @field:JsonProperty("mode") + @SerialName("mode") var mode: NotificationProtocolMode = NotificationProtocolMode.STANDARD, - @field:JsonProperty("title") + @SerialName("title") var title: LanguageText? = null, - @field:JsonProperty("text") + @SerialName("text") var body: LanguageText? = null, - @field:JsonProperty("email") + @SerialName("email") var email: EmailNotificationProtocol = EmailNotificationProtocol(), ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocolMode.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocolMode.kt index 8061bcb41..54072fb43 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocolMode.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/NotificationProtocolMode.kt @@ -16,15 +16,17 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable enum class NotificationProtocolMode { - @JsonProperty("standard") + @SerialName("standard") STANDARD, - @JsonProperty("disabled") + @SerialName("disabled") DISABLED, - @JsonProperty("combined") + @SerialName("combined") COMBINED, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Protocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Protocol.kt index 85143e7a0..57b55cbbe 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Protocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/Protocol.kt @@ -16,12 +16,15 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable + /** * Data Transfer object (DTO) for Protocol. A project may represent a `Protocol` for scheduling * questionnaires. * * @see aRMT Protocols */ +@Serializable data class Protocol( var version: String? = null, var schemaVersion: String? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ProtocolCacheEntry.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ProtocolCacheEntry.kt index 6c50bf2a7..ceb99ff52 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ProtocolCacheEntry.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ProtocolCacheEntry.kt @@ -16,4 +16,7 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable + +@Serializable data class ProtocolCacheEntry(val id: String, val protocol: Protocol?) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestamp.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestamp.kt index 6bd2a7983..934e13560 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestamp.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestamp.kt @@ -16,11 +16,13 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +@Serializable data class ReferenceTimestamp( - @field:JsonProperty("timestamp") + @SerialName("timestamp") var timestamp: String? = null, - @field:JsonProperty("format") + @SerialName("format") var format: ReferenceTimestampType? = null, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestampType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestampType.kt index c3c587b9c..9f311d8c5 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestampType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReferenceTimestampType.kt @@ -16,22 +16,24 @@ package org.radarbase.appserver.jersey.dto.protocol -import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable @Suppress("unused") +@Serializable enum class ReferenceTimestampType { - @JsonProperty("date") + @SerialName("date") DATE, - @JsonProperty("datetime") + @SerialName("datetime") DATETIME, - @JsonProperty("datetimeutc") + @SerialName("datetimeutc") DATETIMEUTC, - @JsonProperty("now") + @SerialName("now") NOW, - @JsonProperty("today") + @SerialName("today") TODAY, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReminderTimePeriod.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReminderTimePeriod.kt index 29897ba03..c3bb28d63 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReminderTimePeriod.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/ReminderTimePeriod.kt @@ -16,10 +16,12 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable import org.radarbase.appserver.jersey.utils.equalTo import org.radarbase.appserver.jersey.utils.stringRepresentation import java.util.Objects +@Serializable class ReminderTimePeriod( val repeat: Int? = null, ) : TimePeriod() { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatProtocol.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatProtocol.kt index d4d7cfc5e..db08dc988 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatProtocol.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatProtocol.kt @@ -17,9 +17,11 @@ package org.radarbase.appserver.jersey.dto.protocol import jakarta.validation.constraints.NotNull +import kotlinx.serialization.Serializable import org.radarbase.appserver.jersey.utils.annotation.CheckExactlyOneNotNull @CheckExactlyOneNotNull(fieldNames = ["amount", "randomAmountBetween"]) +@Serializable data class RepeatProtocol( @field:NotNull var unit: String? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatQuestionnaire.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatQuestionnaire.kt index b2e7b65b7..709e20edf 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatQuestionnaire.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/RepeatQuestionnaire.kt @@ -17,12 +17,14 @@ package org.radarbase.appserver.jersey.dto.protocol import jakarta.validation.constraints.NotNull +import kotlinx.serialization.Serializable import org.radarbase.appserver.jersey.utils.annotation.CheckExactlyOneNotNull /** * Data Transfer object (DTO) for RepeatQuestionnaire. Handles repeat configurations for questionnaires. */ @CheckExactlyOneNotNull(fieldNames = ["unitsFromZero", "randomUnitsFromZeroBetween", "dayOfWeekMap"]) +@Serializable data class RepeatQuestionnaire( @field:NotNull var unit: String? = null, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/TimePeriod.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/TimePeriod.kt index df62d3611..b272904f3 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/TimePeriod.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/dto/protocol/TimePeriod.kt @@ -16,10 +16,12 @@ package org.radarbase.appserver.jersey.dto.protocol +import kotlinx.serialization.Serializable import org.radarbase.appserver.jersey.utils.equalTo import org.radarbase.appserver.jersey.utils.stringRepresentation import java.util.Objects +@Serializable open class TimePeriod(var unit: String? = null, var amount: Int? = null) { override fun toString(): String = stringRepresentation( TimePeriod::amount, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/AppserverResourceEnhancer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/AppserverResourceEnhancer.kt index d7e4c2e1e..21469206c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/AppserverResourceEnhancer.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/AppserverResourceEnhancer.kt @@ -19,10 +19,15 @@ package org.radarbase.appserver.jersey.enhancer import com.google.common.eventbus.EventBus import com.google.firebase.FirebaseOptions import jakarta.inject.Singleton +import kotlinx.coroutines.CoroutineScope import org.glassfish.hk2.api.TypeLiteral import org.glassfish.jersey.internal.inject.AbstractBinder import org.glassfish.jersey.server.ResourceConfig import org.glassfish.jersey.server.validation.ValidationFeature +import org.quartz.JobListener +import org.quartz.Scheduler +import org.quartz.SchedulerListener +import org.radarbase.appserver.jersey.application.event.EventBusStartupListener import org.radarbase.appserver.jersey.config.AppserverConfig import org.radarbase.appserver.jersey.config.FcmServerConfig import org.radarbase.appserver.jersey.dto.ProjectDto @@ -38,51 +43,57 @@ import org.radarbase.appserver.jersey.event.listener.TaskStateEventListener import org.radarbase.appserver.jersey.event.listener.quartz.QuartzMessageJobListener import org.radarbase.appserver.jersey.event.listener.quartz.QuartzMessageSchedulerListener import org.radarbase.appserver.jersey.exception.handler.UnhandledExceptionMapper +import org.radarbase.appserver.jersey.factory.coroutines.SchedulerScopedCoroutine import org.radarbase.appserver.jersey.factory.event.EventBusFactory import org.radarbase.appserver.jersey.factory.fcm.FcmSenderFactory import org.radarbase.appserver.jersey.factory.fcm.FirebaseOptionsFactory +import org.radarbase.appserver.jersey.factory.quartz.QuartzSchedulerFactory +import org.radarbase.appserver.jersey.factory.scheduling.SchedulingServiceFactory import org.radarbase.appserver.jersey.fcm.downstream.FcmSender +import org.radarbase.appserver.jersey.mapper.DataMessageMapper import org.radarbase.appserver.jersey.mapper.Mapper +import org.radarbase.appserver.jersey.mapper.NotificationMapper import org.radarbase.appserver.jersey.mapper.ProjectMapper import org.radarbase.appserver.jersey.mapper.UserMapper -import org.radarbase.appserver.jersey.repository.ProjectRepository -import org.radarbase.appserver.jersey.repository.UserRepository -import org.radarbase.appserver.jersey.repository.impl.ProjectRepositoryImpl -import org.radarbase.appserver.jersey.repository.impl.UserRepositoryImpl -import org.radarbase.appserver.jersey.service.ProjectService -import org.radarbase.appserver.jersey.service.UserService -import org.radarbase.appserver.jersey.service.github.GithubClient -import org.radarbase.appserver.jersey.service.github.GithubService -import org.radarbase.appserver.jersey.service.github.protocol.ProtocolFetcherStrategy -import org.radarbase.appserver.jersey.service.github.protocol.ProtocolGenerator -import org.radarbase.appserver.jersey.service.github.protocol.impl.DefaultProtocolGenerator -import org.radarbase.appserver.jersey.service.github.protocol.impl.GithubProtocolFetcherStrategy -import org.radarbase.appserver.jersey.service.questionnaire_schedule.QuestionnaireScheduleGeneratorService -import org.radarbase.appserver.jersey.service.questionnaire_schedule.ScheduleGeneratorService -import org.radarbase.appserver.jersey.service.scheduling.SchedulingService -import org.radarbase.appserver.jersey.factory.scheduling.SchedulingServiceFactory -import org.radarbase.appserver.jersey.mapper.DataMessageMapper -import org.radarbase.appserver.jersey.mapper.NotificationMapper import org.radarbase.appserver.jersey.repository.DataMessageRepository import org.radarbase.appserver.jersey.repository.DataMessageStateEventRepository import org.radarbase.appserver.jersey.repository.NotificationRepository import org.radarbase.appserver.jersey.repository.NotificationStateEventRepository +import org.radarbase.appserver.jersey.repository.ProjectRepository import org.radarbase.appserver.jersey.repository.TaskRepository import org.radarbase.appserver.jersey.repository.TaskStateEventRepository +import org.radarbase.appserver.jersey.repository.UserRepository import org.radarbase.appserver.jersey.repository.impl.DataMessageRepositoryImpl import org.radarbase.appserver.jersey.repository.impl.DataMessageStateEventRepositoryImpl import org.radarbase.appserver.jersey.repository.impl.NotificationRepositoryImpl import org.radarbase.appserver.jersey.repository.impl.NotificationStateEventRepositoryImpl +import org.radarbase.appserver.jersey.repository.impl.ProjectRepositoryImpl import org.radarbase.appserver.jersey.repository.impl.TaskRepositoryImpl import org.radarbase.appserver.jersey.repository.impl.TaskStateEventRepositoryImpl -import org.radarbase.appserver.jersey.service.DataMessageService +import org.radarbase.appserver.jersey.repository.impl.UserRepositoryImpl +import org.radarbase.appserver.jersey.service.DataMessageStateEventService import org.radarbase.appserver.jersey.service.FcmDataMessageService import org.radarbase.appserver.jersey.service.FcmNotificationService +import org.radarbase.appserver.jersey.service.NotificationStateEventService +import org.radarbase.appserver.jersey.service.ProjectService import org.radarbase.appserver.jersey.service.TaskService +import org.radarbase.appserver.jersey.service.TaskStateEventService +import org.radarbase.appserver.jersey.service.UserService +import org.radarbase.appserver.jersey.service.github.GithubClient +import org.radarbase.appserver.jersey.service.github.GithubService +import org.radarbase.appserver.jersey.service.github.protocol.ProtocolFetcherStrategy +import org.radarbase.appserver.jersey.service.github.protocol.ProtocolGenerator +import org.radarbase.appserver.jersey.service.github.protocol.impl.DefaultProtocolGenerator +import org.radarbase.appserver.jersey.service.github.protocol.impl.GithubProtocolFetcherStrategy import org.radarbase.appserver.jersey.service.quartz.QuartzNamingStrategy +import org.radarbase.appserver.jersey.service.quartz.SchedulerService import org.radarbase.appserver.jersey.service.quartz.SchedulerServiceImpl import org.radarbase.appserver.jersey.service.quartz.SimpleQuartzNamingStrategy -import org.radarbase.appserver.jersey.service.questionnaire_schedule.MessageSchedulerService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.MessageSchedulerService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.QuestionnaireScheduleGeneratorService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.QuestionnaireScheduleService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.ScheduleGeneratorService +import org.radarbase.appserver.jersey.service.scheduling.SchedulingService import org.radarbase.appserver.jersey.service.transmitter.DataMessageTransmitter import org.radarbase.appserver.jersey.service.transmitter.FcmTransmitter import org.radarbase.appserver.jersey.service.transmitter.NotificationTransmitter @@ -115,28 +126,32 @@ class AppserverResourceEnhancer(private val config: AppserverConfig) : JerseyRes .to(UserRepository::class.java) .`in`(Singleton::class.java) - bind(DataMessageRepository::class.java) - .to(DataMessageRepositoryImpl::class.java) + bind(DataMessageRepositoryImpl::class.java) + .to(DataMessageRepository::class.java) .`in`(Singleton::class.java) - bind(NotificationRepository::class.java) - .to(NotificationRepositoryImpl::class.java) + bind(NotificationRepositoryImpl::class.java) + .to(NotificationRepository::class.java) .`in`(Singleton::class.java) - bind(TaskRepository::class.java) - .to(TaskRepositoryImpl::class.java) + bind(TaskRepositoryImpl::class.java) + .to(TaskRepository::class.java) .`in`(Singleton::class.java) - bind(DataMessageStateEventRepository::class.java) - .to(DataMessageStateEventRepositoryImpl::class.java) + bind(DataMessageStateEventRepositoryImpl::class.java) + .to(DataMessageStateEventRepository::class.java) .`in`(Singleton::class.java) - bind(NotificationStateEventRepository::class.java) - .to(NotificationStateEventRepositoryImpl::class.java) + bind(NotificationStateEventRepositoryImpl::class.java) + .to(NotificationStateEventRepository::class.java) .`in`(Singleton::class.java) - bind(TaskStateEventRepository::class.java) - .to(TaskStateEventRepositoryImpl::class.java) + bind(TaskStateEventRepositoryImpl::class.java) + .to(TaskStateEventRepository::class.java) + .`in`(Singleton::class.java) + + bind(TaskStateEventService::class.java) + .to(TaskStateEventService::class.java) .`in`(Singleton::class.java) bind(ProjectMapper::class.java) @@ -199,30 +214,40 @@ class AppserverResourceEnhancer(private val config: AppserverConfig) : JerseyRes .to(ScheduleGeneratorService::class.java) .`in`(Singleton::class.java) + bind(QuestionnaireScheduleService::class.java) + .to(QuestionnaireScheduleService::class.java) + .`in`(Singleton::class.java) + bind(QuartzMessageSchedulerListener::class.java) - .to(QuartzMessageSchedulerListener::class.java) + .to(SchedulerListener::class.java) .`in`(Singleton::class.java) bind(QuartzMessageJobListener::class.java) - .to(QuartzMessageJobListener::class.java) + .to(JobListener::class.java) + .`in`(Singleton::class.java) + + bind(SchedulerServiceImpl::class.java) + .to(SchedulerService::class.java) + .`in`(Singleton::class.java) + + bind(MessageSchedulerService::class.java) + .to(object : TypeLiteral>() {}.type) .`in`(Singleton::class.java) bind(MessageSchedulerService::class.java) - .to(MessageSchedulerService::class.java) + .to(object : TypeLiteral>() {}.type) .`in`(Singleton::class.java) bind(SimpleQuartzNamingStrategy::class.java) .to(QuartzNamingStrategy::class.java) .`in`(Singleton::class.java) - bind(SchedulerServiceImpl::class.java) - .to(SchedulingService::class.java) + bind(FcmTransmitter::class.java) + .to(NotificationTransmitter::class.java) .`in`(Singleton::class.java) bind(FcmTransmitter::class.java) .to(DataMessageTransmitter::class.java) - .to(NotificationTransmitter::class.java) - .to(FcmTransmitter::class.java) .`in`(Singleton::class.java) bind(TaskStateEventListener::class.java) @@ -233,10 +258,26 @@ class AppserverResourceEnhancer(private val config: AppserverConfig) : JerseyRes .to(MessageStateEventListener::class.java) .`in`(Singleton::class.java) + bind(DataMessageStateEventService::class.java) + .to(DataMessageStateEventService::class.java) + .`in`(Singleton::class.java) + + bind(NotificationStateEventService::class.java) + .to(NotificationStateEventService::class.java) + .`in`(Singleton::class.java) + + bindFactory(SchedulerScopedCoroutine::class.java) + .to(CoroutineScope::class.java) + .`in`(Singleton::class.java) + bindFactory(EventBusFactory::class.java) .to(EventBus::class.java) .`in`(Singleton::class.java) + bindFactory(QuartzSchedulerFactory::class.java) + .to(Scheduler::class.java) + .`in`(Singleton::class.java) + bindFactory(SchedulingServiceFactory::class.java) .to(SchedulingService::class.java) .`in`(Singleton::class.java) @@ -248,11 +289,29 @@ class AppserverResourceEnhancer(private val config: AppserverConfig) : JerseyRes bindFactory(FcmSenderFactory::class.java) .to(FcmSender::class.java) .`in`(Singleton::class.java) + + bind(UnverifiedProjectService::class.java) + .to(org.radarbase.jersey.service.ProjectService::class.java) + .`in`(Singleton::class.java) } override fun ResourceConfig.enhance() { register(ValidationFeature::class.java) register(UnhandledExceptionMapper::class.java) + register(EventBusStartupListener::class.java) + } + + /** Project service without validation of the project's existence. */ + class UnverifiedProjectService : org.radarbase.jersey.service.ProjectService { + override suspend fun ensureOrganization(organizationId: String) = Unit + + override suspend fun ensureProject(projectId: String) = Unit + + override suspend fun ensureSubject(projectId: String, userId: String) = Unit + + override suspend fun listProjects(organizationId: String): List = emptyList() + + override suspend fun projectOrganization(projectId: String): String = "main" } companion object { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/factory/AppserverResourceEnhancerFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/factory/AppserverResourceEnhancerFactory.kt index b99fd714c..5f941451c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/factory/AppserverResourceEnhancerFactory.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/enhancer/factory/AppserverResourceEnhancerFactory.kt @@ -44,9 +44,7 @@ class AppserverResourceEnhancerFactory(private val config: AppserverConfig) : En user = config.db.username, password = config.db.password, dialect = config.db.hibernateDialect, - properties = mapOf( - "jakarta.persistence.schema-generation.database.action" to "drop-and-create", - ), + properties = config.db.additionalProperties, liquibase = org.radarbase.jersey.hibernate.config.LiquibaseConfig( enable = config.db.liquibase.enabled, ), diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/AuditModel.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/AuditModel.kt index 94134a1b8..0ba40f917 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/AuditModel.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/AuditModel.kt @@ -16,19 +16,23 @@ package org.radarbase.appserver.jersey.entity -import java.time.Instant -import org.hibernate.annotations.CreationTimestamp -import org.hibernate.annotations.UpdateTimestamp import jakarta.persistence.Column import jakarta.persistence.MappedSuperclass +import jakarta.persistence.Temporal +import jakarta.persistence.TemporalType +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.UpdateTimestamp +import java.util.Date @MappedSuperclass abstract class AuditModel { @CreationTimestamp + @Temporal(TemporalType.TIMESTAMP) @Column(name = "created_at", nullable = false, updatable = false) - var createdAt: Instant? = null + var createdAt: Date? = null @UpdateTimestamp + @Temporal(TemporalType.TIMESTAMP) @Column(name = "updated_at", nullable = false) - var updatedAt: Instant? = null + var updatedAt: Date? = null } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessage.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessage.kt index 70e1c36a6..c7cc3af6e 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessage.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessage.kt @@ -41,21 +41,22 @@ import java.time.Instant @Suppress("unused") @Entity @Table( - name = "data_messages", uniqueConstraints = [ + name = "data_messages", + uniqueConstraints = [ UniqueConstraint( columnNames = [ - "user_id", "source_id", "scheduled_time", "ttl_seconds", "delivered", "dry_run" - ] - ) - ] + "user_id", "source_id", "scheduled_time", "ttl_seconds", "delivered", "dry_run", + ], + ), + ], ) @Inheritance(strategy = InheritanceType.TABLE_PER_CLASS) class DataMessage : Message() { @Nullable @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "data_message_map") - @MapKeyColumn(name = "key", nullable = true) - @Column(name = "value") + @MapKeyColumn(name = "data_message_key", nullable = true) + @Column(name = "data_message_value") var dataMap: MutableMap? = null class DataMessageBuilder(dataMessage: DataMessage? = null) { @@ -132,7 +133,6 @@ class DataMessage : Message() { this.mutableContent = mutableContent } - fun dataMap(dataMap: MutableMap?): DataMessageBuilder = apply { this.dataMap = dataMap } @@ -160,8 +160,6 @@ class DataMessage : Message() { } } - - override fun toString(): String { return "DataMessage(dataMap=$dataMap)" } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessageStateEvent.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessageStateEvent.kt index d99dd9cdd..96ae03f2e 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessageStateEvent.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/DataMessageStateEvent.kt @@ -17,7 +17,14 @@ package org.radarbase.appserver.jersey.entity import com.fasterxml.jackson.annotation.JsonIgnore -import jakarta.persistence.* +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table import jakarta.validation.constraints.NotNull import org.hibernate.annotations.OnDelete import org.hibernate.annotations.OnDeleteAction diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Message.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Message.kt index 5c9c2f948..a5b45c281 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Message.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Message.kt @@ -98,7 +98,7 @@ class Message( var priority: String? = null, @Column(name = "mutable_content") - var mutableContent: Boolean = false + var mutableContent: Boolean = false, ) : AuditModel(), Serializable, Scheduled { override fun equals(other: Any?): Boolean = equalTo( @@ -122,7 +122,7 @@ class Message( delivered, dryRun, appPackage, - sourceType + sourceType, ) } @@ -130,7 +130,6 @@ class Message( return "Message(id=$id, user=$user, task=$task, sourceId=$sourceId, scheduledTime=$scheduledTime, ttlSeconds=$ttlSeconds, fcmMessageId=$fcmMessageId, fcmTopic=$fcmTopic, fcmCondition=$fcmCondition, delivered=$delivered, validated=$validated, appPackage=$appPackage, sourceType=$sourceType, dryRun=$dryRun, priority=$priority, mutableContent=$mutableContent)" } - companion object { @Serial private const val serialVersionUID = -367424816328519L diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/MessageStateEvent.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/MessageStateEvent.kt index 11417da5c..628a229ae 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/MessageStateEvent.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/MessageStateEvent.kt @@ -37,4 +37,4 @@ class MessageStateEvent( @Column(name = "associated_info", length = 1250) var associatedInfo: String? = null, - ) +) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Notification.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Notification.kt index 3ee9de1fd..8f2609f22 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Notification.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Notification.kt @@ -32,19 +32,21 @@ import java.util.Objects @Suppress("unused") @Table( name = "notifications", - uniqueConstraints = [UniqueConstraint( - columnNames = [ - "user_id", - "source_id", - "scheduled_time", - "title", - "body", - "type", - "ttl_seconds", - "delivered", - "dry_run" - ] - )] + uniqueConstraints = [ + UniqueConstraint( + columnNames = [ + "user_id", + "source_id", + "scheduled_time", + "title", + "body", + "type", + "ttl_seconds", + "delivered", + "dry_run", + ], + ), + ], ) @Entity class Notification : Message() { @@ -147,7 +149,6 @@ class Notification : Message() { private var emailTitle: String? = notification?.emailTitle private var emailBody: String? = notification?.emailBody - fun id(id: Long?): NotificationBuilder = apply { this.id = id return this @@ -218,7 +219,6 @@ class Notification : Message() { return this } - fun title(title: String?): NotificationBuilder = apply { this.title = title return this @@ -370,10 +370,10 @@ class Notification : Message() { return false } val that = other - return super.equals(other) - && title == that.title - && body == that.body - && type == that.type + return super.equals(other) && + title == that.title && + body == that.body && + type == that.type } override fun hashCode(): Int { @@ -381,7 +381,7 @@ class Notification : Message() { super.hashCode(), title, body, - type + type, ) } @@ -389,7 +389,6 @@ class Notification : Message() { return "Notification(title=$title, body=$body, type=$type, sound=$sound, badge=$badge, subtitle=$subtitle, icon=$icon, color=$color, bodyLocKey=$bodyLocKey, bodyLocArgs=$bodyLocArgs, titleLocKey=$titleLocKey, titleLocArgs=$titleLocArgs, androidChannelId=$androidChannelId, tag=$tag, clickAction=$clickAction, emailEnabled=$emailEnabled, emailTitle=$emailTitle, emailBody=$emailBody, additionalData=$additionalData, Message=${super.toString()})" } - companion object { @Serial private const val serialVersionUID = 6L diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Project.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Project.kt index 7c947061a..a233a9e19 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Project.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/Project.kt @@ -16,7 +16,12 @@ package org.radarbase.appserver.jersey.entity -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table import jakarta.validation.constraints.NotNull import org.radarbase.appserver.jersey.utils.stringRepresentation import java.util.Objects diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/TaskStateEvent.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/TaskStateEvent.kt index 38c51a8ff..30c2aa7d3 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/TaskStateEvent.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/TaskStateEvent.kt @@ -17,7 +17,17 @@ package org.radarbase.appserver.jersey.entity import com.fasterxml.jackson.annotation.JsonIgnore -import jakarta.persistence.* +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table import jakarta.validation.constraints.NotNull import org.hibernate.annotations.OnDelete import org.hibernate.annotations.OnDeleteAction @@ -52,7 +62,7 @@ class TaskStateEvent { var task: Task? = null constructor() - + constructor(task: Task?, state: TaskState?, time: Instant?, associatedInfo: String?) { this.state = state this.time = time diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/User.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/User.kt index ec1383416..b91a61aa9 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/User.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/entity/User.kt @@ -42,7 +42,7 @@ import java.time.Instant import java.util.Objects /** - * [Entity] for persisting users. The corresponding DTO is [org.radarbase.appserver.dto.fcm.FcmUserDto]. + * [Entity] for persisting users. The corresponding DTO is [org.radarbase.appserver.jersey.dto.fcm.FcmUserDto]. * [Project] can have multiple [User] (Many-to-One). */ @Table( @@ -113,7 +113,6 @@ class User( User::language, ) - override fun equals(other: Any?): Boolean = equalTo( other, User::subjectId, diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/MessageStateEventListener.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/MessageStateEventListener.kt index d8d269130..c522a07db 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/MessageStateEventListener.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/MessageStateEventListener.kt @@ -16,23 +16,51 @@ package org.radarbase.appserver.jersey.event.listener -import com.fasterxml.jackson.core.JsonProcessingException -import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.eventbus.AllowConcurrentEvents import com.google.common.eventbus.Subscribe import jakarta.inject.Inject +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.glassfish.hk2.api.ServiceLocator import org.radarbase.appserver.jersey.entity.DataMessageStateEvent import org.radarbase.appserver.jersey.entity.NotificationStateEvent import org.radarbase.appserver.jersey.event.state.dto.DataMessageStateEventDto import org.radarbase.appserver.jersey.event.state.dto.NotificationStateEventDto +import org.radarbase.appserver.jersey.service.DataMessageStateEventService +import org.radarbase.appserver.jersey.service.NotificationStateEventService +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.Logger import org.slf4j.LoggerFactory +@Suppress("unused") class MessageStateEventListener @Inject constructor( - private val objectMapper: ObjectMapper, - private val notificationStateEventService: NotificationStateEventService, - private val dataMessageStateEventService: DataMessageStateEventService + private val asyncService: AsyncCoroutineService, + private val serviceLocator: ServiceLocator, ) { + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + + private var notificationStateEventService: NotificationStateEventService? = null + get() { + if (field == null) { + return serviceLocator.getService(NotificationStateEventService::class.java) + ?.also { field = it } + } + return field + } + + private var dataMessageStateEventService: DataMessageStateEventService? = null + get() { + if (field == null) { + return serviceLocator.getService(DataMessageStateEventService::class.java) + ?.also { field = it } + } + return field + } + /** * Handle an application event. * @@ -42,33 +70,46 @@ class MessageStateEventListener @Inject constructor( @AllowConcurrentEvents fun onNotificationStateChange(event: NotificationStateEventDto) { val info = convertMapToString(event.additionalInfo) - logger.debug("ID: {}, STATE: {}.", event.notification.id, event.state) + logger.info("Notification state changed. ID: {}, STATE: {}.", event.notification.id, event.state) val eventEntity = NotificationStateEvent( - event.notification, event.state, event.time, info + event.notification, + event.state, + event.time, + info, ) - notificationStateEventService.addNotificationStateEvent(eventEntity) + asyncService.runBlocking { + notificationStateEventService?.addNotificationStateEvent(eventEntity) + ?: logger.error("NotificationStateEventService is not initialized.") + } } @Subscribe @AllowConcurrentEvents fun onDataMessageStateChange(event: DataMessageStateEventDto) { val info = convertMapToString(event.additionalInfo) - logger.debug("ID: {}, STATE: {}", event.dataMessage.id, event.state) + logger.debug("Data Message state changed. ID: {}, STATE: {}", event.dataMessage.id, event.state) val eventEntity = DataMessageStateEvent( - event.dataMessage, event.state, event.time, info + event.dataMessage, + event.state, + event.time, + info, ) - dataMessageStateEventService.addDataMessageStateEvent(eventEntity) + asyncService.runBlocking { + dataMessageStateEventService?.addDataMessageStateEvent(eventEntity) + ?: logger.error("DataMessageStateEventService is not initialized.") + } } fun convertMapToString(additionalInfoMap: Map?): String? { - if (additionalInfoMap == null) { - return null - } - try { - return objectMapper.writeValueAsString(additionalInfoMap) - } catch (_: JsonProcessingException) { - logger.warn("error processing event's additional info: {}", additionalInfoMap) - return null + if (additionalInfoMap == null) return null + return try { + json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + additionalInfoMap, + ) + } catch (e: Exception) { + logger.warn("error processing event's additional info: {}", additionalInfoMap, e) + null } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/TaskStateEventListener.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/TaskStateEventListener.kt index c41cea699..a56dfa3fc 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/TaskStateEventListener.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/TaskStateEventListener.kt @@ -16,19 +16,38 @@ package org.radarbase.appserver.jersey.event.listener -import com.fasterxml.jackson.core.JsonProcessingException -import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.eventbus.AllowConcurrentEvents import com.google.common.eventbus.Subscribe import jakarta.inject.Inject +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.glassfish.hk2.api.ServiceLocator import org.radarbase.appserver.jersey.entity.TaskStateEvent import org.radarbase.appserver.jersey.event.state.dto.TaskStateEventDto +import org.radarbase.appserver.jersey.service.TaskStateEventService +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.LoggerFactory +@Suppress("unused") class TaskStateEventListener @Inject constructor( - private val objectMapper: ObjectMapper, - private val taskStateEventService: TaskStateEventService, + private val asyncService: AsyncCoroutineService, + private val serviceLocator: ServiceLocator, ) { + private var taskStateEventService: TaskStateEventService? = null + get() { + if (field == null) { + return serviceLocator.getService(TaskStateEventService::class.java) + ?.also { field = it } + } + return field + } + + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } + /** * Handle an application event. * // we can add more event listeners by annotating with @EventListener @@ -39,22 +58,29 @@ class TaskStateEventListener @Inject constructor( @AllowConcurrentEvents fun onTaskStateChange(event: TaskStateEventDto) { val info = convertMapToString(event.additionalInfo) - logger.debug("ID: {}, STATE: {}", event.task?.id, event.state) + logger.info("Task state changed. ID: {}, STATE: {}", event.task?.id, event.state) val eventEntity = TaskStateEvent( - event.task, event.state, event.time, info, + event.task, + event.state, + event.time, + info, ) - taskStateEventService.addTaskStateEvent(eventEntity) + asyncService.runBlocking { + taskStateEventService?.addTaskStateEvent(eventEntity) + ?: logger.error("TaskStateEventService is not initialized.") + } } fun convertMapToString(additionalInfoMap: Map?): String? { - if (additionalInfoMap == null) { - return null - } - try { - return objectMapper.writeValueAsString(additionalInfoMap) - } catch (_: JsonProcessingException) { - logger.warn("error processing event's additional info: {}", additionalInfoMap) - return null + if (additionalInfoMap == null) return null + return try { + json.encodeToString( + MapSerializer(String.serializer(), String.serializer()), + additionalInfoMap, + ) + } catch (e: Exception) { + logger.warn("error processing event's additional info: {}", additionalInfoMap, e) + null } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageJobListener.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageJobListener.kt index c1b004942..05e35813b 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageJobListener.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageJobListener.kt @@ -18,7 +18,6 @@ package org.radarbase.appserver.jersey.event.listener.quartz import com.google.common.eventbus.EventBus import jakarta.inject.Inject -import kotlinx.coroutines.runBlocking import org.quartz.JobExecutionContext import org.quartz.JobExecutionException import org.quartz.JobListener @@ -28,6 +27,7 @@ import org.radarbase.appserver.jersey.event.state.dto.NotificationStateEventDto import org.radarbase.appserver.jersey.repository.DataMessageRepository import org.radarbase.appserver.jersey.repository.NotificationRepository import org.radarbase.appserver.jersey.service.quartz.MessageType +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.Logger import org.slf4j.LoggerFactory import java.time.Instant @@ -36,6 +36,7 @@ class QuartzMessageJobListener @Inject constructor( private val messageStateEventPublisher: EventBus, private val notificationRepository: NotificationRepository, private val dataMessageRepository: DataMessageRepository, + private val asyncService: AsyncCoroutineService, ) : JobListener { /** * Get the name of the `JobListener`. @@ -75,49 +76,50 @@ class QuartzMessageJobListener @Inject constructor( override fun jobWasExecuted(context: JobExecutionContext, jobException: JobExecutionException?) { val jobDataMap = context.mergedJobDataMap val messageId = jobDataMap.getLongValue("messageId") - val messageType = jobDataMap.getString("messageType") - if (messageType == null) { - log.warn("The message type does not exist.") + val messageType = jobDataMap.getString("messageType") ?: run { + logger.warn("Message type does not exist.") return } val type = MessageType.valueOf(messageType) when (type) { MessageType.NOTIFICATION -> { - val notification = runBlocking { + val notification = asyncService.runBlocking { notificationRepository.find(messageId) - } - if (notification == null) { - log.warn("The notification does not exist in database and yet was scheduled.") + } ?: run { + logger.warn("The notification does not exist in database and yet was scheduled.") return } if (jobException != null) { val additionalInfo: MutableMap = hashMapOf() additionalInfo.put("error", jobException.message!!) additionalInfo.put("error_description", jobException.toString()) - val notificationStateEventError = - NotificationStateEventDto( - notification, MessageState.ERRORED, additionalInfo, Instant.now(), - ) + val notificationStateEventError = NotificationStateEventDto( + notification, + MessageState.ERRORED, + additionalInfo, + Instant.now(), + ) messageStateEventPublisher.post(notificationStateEventError) - log.warn("The job could not be executed.", jobException) + logger.warn("The job could not be executed.", jobException) return } - val notificationStateEvent = - NotificationStateEventDto( - notification, MessageState.EXECUTED, null, Instant.now(), - ) + val notificationStateEvent = NotificationStateEventDto( + notification, + MessageState.EXECUTED, + null, + Instant.now(), + ) messageStateEventPublisher.post(notificationStateEvent) } MessageType.DATA -> { - val dataMessage = runBlocking { + val dataMessage = asyncService.runBlocking { dataMessageRepository.find(messageId) - } - if (dataMessage == null) { - log.warn("The data message does not exist in database and yet was scheduled.") + } ?: run { + logger.warn("The data message does not exist in database and yet was scheduled.") return } @@ -126,26 +128,31 @@ class QuartzMessageJobListener @Inject constructor( additionalInfo.put("error", jobException.message!!) additionalInfo.put("error_description", jobException.toString()) val dataMessageStateEventError = DataMessageStateEventDto( - dataMessage, MessageState.ERRORED, additionalInfo, Instant.now(), + dataMessage, + MessageState.ERRORED, + additionalInfo, + Instant.now(), ) messageStateEventPublisher.post(dataMessageStateEventError) - log.warn("The job could not be executed.", jobException) + logger.warn("The job could not be executed.", jobException) return } - val dataMessageStateEvent = - DataMessageStateEventDto( - dataMessage, MessageState.EXECUTED, null, Instant.now(), - ) + val dataMessageStateEvent = DataMessageStateEventDto( + dataMessage, + MessageState.EXECUTED, + null, + Instant.now(), + ) messageStateEventPublisher.post(dataMessageStateEvent) } - else -> log.warn("The message type does not exist.") + MessageType.UNKNOWN -> logger.warn("The message type does not exist.") } } companion object { - private val log: Logger = LoggerFactory.getLogger(QuartzMessageJobListener::class.java) + private val logger: Logger = LoggerFactory.getLogger(QuartzMessageJobListener::class.java) } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageSchedulerListener.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageSchedulerListener.kt index fcef3cee1..a8c43e4ad 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageSchedulerListener.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/event/listener/quartz/QuartzMessageSchedulerListener.kt @@ -17,6 +17,7 @@ package org.radarbase.appserver.jersey.event.listener.quartz import com.google.common.eventbus.EventBus +import jakarta.inject.Inject import kotlinx.coroutines.runBlocking import org.quartz.JobDetail import org.quartz.JobKey @@ -33,15 +34,17 @@ import org.radarbase.appserver.jersey.repository.NotificationRepository import org.radarbase.appserver.jersey.service.quartz.MessageType import org.radarbase.appserver.jersey.service.quartz.QuartzNamingStrategy import org.radarbase.appserver.jersey.service.quartz.SimpleQuartzNamingStrategy +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.Logger import org.slf4j.LoggerFactory import java.time.Instant -class QuartzMessageSchedulerListener( +class QuartzMessageSchedulerListener @Inject constructor( private val messageStateEventPublisher: EventBus, private val notificationRepository: NotificationRepository, private val dataMessageRepository: DataMessageRepository, - private val scheduler: Scheduler + private val scheduler: Scheduler, + private val asyncService: AsyncCoroutineService, ) : SchedulerListener { /** * Called by the `[Scheduler]` when a `[JobDetail]` is @@ -52,9 +55,9 @@ class QuartzMessageSchedulerListener( try { jobDetail = scheduler.getJobDetail(trigger.jobKey) } catch (exc: SchedulerException) { - log.warn( + logger.warn( "Encountered error while getting job information from Trigger: ", - exc + exc, ) return } @@ -65,35 +68,39 @@ class QuartzMessageSchedulerListener( when (type) { MessageType.NOTIFICATION -> { - val notification = runBlocking { + val notification = asyncService.runBlocking { notificationRepository.find(messageId) - } - if (notification == null) { - log.warn("The notification does not exist in database and yet was scheduled.") + } ?: run { + logger.warn("The notification does not exist in database and yet was scheduled.") return } val notificationStateEvent = NotificationStateEventDto( - notification, MessageState.SCHEDULED, null, Instant.now() + notification, + MessageState.SCHEDULED, + null, + Instant.now(), ) messageStateEventPublisher.post(notificationStateEvent) } MessageType.DATA -> { - val dataMessage = runBlocking { + val dataMessage = asyncService.runBlocking { dataMessageRepository.find(messageId) - } - if (dataMessage == null) { - log.warn("The data message does not exist in database and yet was scheduled.") + } ?: run { + logger.warn("The data message does not exist in database and yet was scheduled.") return } val dataMessageStateEvent = DataMessageStateEventDto( - dataMessage, MessageState.SCHEDULED, null, Instant.now() + dataMessage, + MessageState.SCHEDULED, + null, + Instant.now(), ) messageStateEventPublisher.post(dataMessageStateEvent) } - else -> {} + MessageType.UNKNOWN -> logger.warn("Job is scheduled for unknown message type") } } @@ -106,23 +113,25 @@ class QuartzMessageSchedulerListener( override fun jobUnscheduled(triggerKey: TriggerKey) { val notificationId: Long try { - notificationId = NAMING_STRATEGY.getMessageId(triggerKey.name)!!.toLong() + notificationId = requireNotNull(NAMING_STRATEGY.getMessageId(triggerKey.name)) { + "Message ID shouldn't be null when retrieving from naming strategy" + }.toLong() } catch (_: NumberFormatException) { - log.warn("The message id could not be established from unscheduled trigger.") + logger.warn("The message id could not be established from unscheduled trigger.") return } val notification = runBlocking { notificationRepository.find(notificationId) - } - - if (notification == null) { - log.warn("The notification does not exist in database and yet was unscheduled.") + } ?: run { + logger.warn("The notification does not exist in database and yet was unscheduled.") return } - val notificationStateEvent = - NotificationStateEventDto( - notification, MessageState.CANCELLED, null, Instant.now() - ) + val notificationStateEvent = NotificationStateEventDto( + notification, + MessageState.CANCELLED, + null, + Instant.now(), + ) messageStateEventPublisher.post(notificationStateEvent) } @@ -281,6 +290,6 @@ class QuartzMessageSchedulerListener( companion object { private val NAMING_STRATEGY: QuartzNamingStrategy = SimpleQuartzNamingStrategy() - private val log: Logger = LoggerFactory.getLogger(QuartzMessageSchedulerListener::class.java) + private val logger: Logger = LoggerFactory.getLogger(QuartzMessageSchedulerListener::class.java) } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/AlreadyExistsException.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/AlreadyExistsException.kt index 9ff29a38a..1c75bd799 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/AlreadyExistsException.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/AlreadyExistsException.kt @@ -20,7 +20,7 @@ import jakarta.ws.rs.core.Response import org.radarbase.jersey.exception.HttpApplicationException class AlreadyExistsException(code: String, message: String) : HttpApplicationException( - Response.Status.INTERNAL_SERVER_ERROR, + Response.Status.EXPECTATION_FAILED, code, message, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidNotificationDetailsException.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidNotificationDetailsException.kt index 77a244913..e46d1db61 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidNotificationDetailsException.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidNotificationDetailsException.kt @@ -20,7 +20,7 @@ import jakarta.ws.rs.core.Response import org.radarbase.jersey.exception.HttpApplicationException class InvalidNotificationDetailsException(message: String) : HttpApplicationException( - Response.Status.INTERNAL_SERVER_ERROR, + Response.Status.EXPECTATION_FAILED, "invalid_notification_details", message, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidProjectDetailsException.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidProjectDetailsException.kt index 90af8f0c5..310693ca9 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidProjectDetailsException.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidProjectDetailsException.kt @@ -20,7 +20,7 @@ import jakarta.ws.rs.core.Response import org.radarbase.jersey.exception.HttpApplicationException class InvalidProjectDetailsException(message: String) : HttpApplicationException( - Response.Status.INTERNAL_SERVER_ERROR, + Response.Status.EXPECTATION_FAILED, "invalid_project_details", message, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidUserDetailsException.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidUserDetailsException.kt index e6bb2687f..74045b549 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidUserDetailsException.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/InvalidUserDetailsException.kt @@ -20,7 +20,7 @@ import jakarta.ws.rs.core.Response import org.radarbase.jersey.exception.HttpApplicationException class InvalidUserDetailsException(message: String) : HttpApplicationException( - Response.Status.INTERNAL_SERVER_ERROR, + Response.Status.EXPECTATION_FAILED, "invalid_user_details", message, ) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/MessageTransmitException.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/MessageTransmitException.kt index 57c7bd2c0..9d4de8e17 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/MessageTransmitException.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/exception/MessageTransmitException.kt @@ -31,5 +31,4 @@ open class MessageTransmitException : HttpApplicationException { code, message, ) - } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/coroutines/SchedulerScopedCoroutine.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/coroutines/SchedulerScopedCoroutine.kt new file mode 100644 index 000000000..adcd7ad3a --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/coroutines/SchedulerScopedCoroutine.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.factory.coroutines + +import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import org.glassfish.jersey.internal.inject.DisposableSupplier +import org.radarbase.appserver.jersey.config.AppserverConfig + +class SchedulerScopedCoroutine @Inject constructor( + appserverConfig: AppserverConfig, +) : DisposableSupplier { + private val schedulerConfig = appserverConfig.quartz + + private val dispatcher: CoroutineDispatcher = when (schedulerConfig.coroutineDispatcher) { + "default" -> Dispatchers.Default + "io" -> Dispatchers.IO + else -> Dispatchers.Unconfined + } + + private val job: Job = when (schedulerConfig.coroutineJob) { + "supervisor-job" -> SupervisorJob() + "coroutine-job" -> Job() + else -> error("Unknown coroutine job for quartz scheduler. Select either 'supervisor-job' or 'coroutine-job'") + } + + private val scope: CoroutineScope = CoroutineScope(dispatcher + job) + + override fun get(): CoroutineScope = scope + + override fun dispose(instance: CoroutineScope) { + scope.cancel() + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/event/EventBusFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/event/EventBusFactory.kt index a0bad817f..ff8dc34cc 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/event/EventBusFactory.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/event/EventBusFactory.kt @@ -21,12 +21,8 @@ import jakarta.inject.Inject import org.glassfish.jersey.internal.inject.DisposableSupplier import org.radarbase.appserver.jersey.config.AppserverConfig import org.radarbase.appserver.jersey.event.AppserverEventBus -import org.radarbase.appserver.jersey.event.listener.MessageStateEventListener -import org.radarbase.appserver.jersey.event.listener.TaskStateEventListener class EventBusFactory @Inject constructor( - private val taskStateEventListener: TaskStateEventListener, - private val messageStateEventListener: MessageStateEventListener, config: AppserverConfig, ) : DisposableSupplier { private val appserverEventBus = AppserverEventBus( @@ -36,10 +32,7 @@ class EventBusFactory @Inject constructor( ) override fun get(): EventBus { - return appserverEventBus.getEventBus().also { - it.register(taskStateEventListener) - it.register(messageStateEventListener) - } + return appserverEventBus.getEventBus() } override fun dispose(instance: EventBus?) { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/fcm/FirebaseOptionsFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/fcm/FirebaseOptionsFactory.kt index c4dfd2774..d78428e99 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/fcm/FirebaseOptionsFactory.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/fcm/FirebaseOptionsFactory.kt @@ -26,8 +26,8 @@ import java.util.Base64 import java.util.function.Supplier class FirebaseOptionsFactory @Inject constructor( - private val serverConfig: FcmServerConfig -): Supplier { + private val serverConfig: FcmServerConfig, +) : Supplier { override fun get(): FirebaseOptions { var googleCredentials: GoogleCredentials? = null diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/HK2JobFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/HK2JobFactory.kt new file mode 100644 index 000000000..68565dc2e --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/HK2JobFactory.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.factory.quartz + +import org.glassfish.hk2.api.ServiceLocator +import org.quartz.Job +import org.quartz.Scheduler +import org.quartz.SchedulerException +import org.quartz.spi.JobFactory +import org.quartz.spi.TriggerFiredBundle + +class HK2JobFactory( + private val serviceLocator: ServiceLocator, +) : JobFactory { + + @Throws(SchedulerException::class) + override fun newJob(bundle: TriggerFiredBundle, scheduler: Scheduler): Job { + val jobClass = bundle.jobDetail.jobClass + return serviceLocator.create(jobClass) + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/QuartzSchedulerFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/QuartzSchedulerFactory.kt new file mode 100644 index 000000000..15a6d4757 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/factory/quartz/QuartzSchedulerFactory.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.factory.quartz + +import jakarta.inject.Inject +import org.glassfish.hk2.api.ServiceLocator +import org.glassfish.jersey.internal.inject.DisposableSupplier +import org.quartz.Scheduler +import org.quartz.SchedulerFactory +import org.quartz.impl.StdSchedulerFactory +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class QuartzSchedulerFactory @Inject constructor( + private val serviceLocator: ServiceLocator, +) : DisposableSupplier { + val schedulerFactory: SchedulerFactory = StdSchedulerFactory() + var scheduler: Scheduler? = null + + override fun get(): Scheduler { + logger.info("Retrieving quartz scheduler instance") + return scheduler.let { sch -> + if (sch == null || sch.isShutdown) { + scheduler = schedulerFactory.scheduler.also { + if (!it.isStarted) { + it.start() + } + it.setJobFactory(HK2JobFactory(serviceLocator)) + } + } + scheduler!! + } + } + + override fun dispose(instance: Scheduler?) { + logger.info("Disposing quartz scheduler") + scheduler?.shutdown() + } + + companion object { + private val logger: Logger = LoggerFactory.getLogger(QuartzSchedulerFactory::class.java) + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/downstream/AdminSdkFcmSender.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/downstream/AdminSdkFcmSender.kt index b46a6310e..a9b4613fd 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/downstream/AdminSdkFcmSender.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/downstream/AdminSdkFcmSender.kt @@ -31,6 +31,7 @@ import com.google.firebase.messaging.Notification import org.radarbase.appserver.jersey.fcm.model.FcmDataMessage import org.radarbase.appserver.jersey.fcm.model.FcmDownstreamMessage import org.radarbase.appserver.jersey.fcm.model.FcmNotificationMessage +import org.radarbase.appserver.jersey.utils.requireNotNullField import org.slf4j.LoggerFactory import java.time.Duration import java.time.Instant @@ -53,7 +54,7 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { .setFcmOptions(FcmOptions.builder().build()) .setCondition(downstreamMessage.condition) - val ttl = getValidTtlMillis(downstreamMessage.timeToLive!!) + val ttl = getValidTtlMillis(requireNotNullField(downstreamMessage.timeToLive, "Downstream message time to live")) when (downstreamMessage) { is FcmNotificationMessage -> { @@ -62,9 +63,13 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { AndroidConfig.builder().run { setCollapseKey(downstreamMessage.collapseKey) setPriority( - if (priority == null) AndroidConfig.Priority.HIGH else AndroidConfig.Priority.valueOf( - priority, - ), + if (priority == null) { + AndroidConfig.Priority.HIGH + } else { + AndroidConfig.Priority.valueOf( + priority, + ) + }, ) setTtl(ttl.toMillis()) setNotification(getAndroidNotification(downstreamMessage)) @@ -104,9 +109,13 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { AndroidConfig.builder().run { setCollapseKey(downstreamMessage.collapseKey) setPriority( - if (priority == null) AndroidConfig.Priority.NORMAL else AndroidConfig.Priority.valueOf( - priority, - ), + if (priority == null) { + AndroidConfig.Priority.NORMAL + } else { + AndroidConfig.Priority.valueOf( + priority, + ) + }, ) setTtl(ttl.toMillis()) putAllData(downstreamMessage.data) @@ -128,42 +137,32 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { } } - private fun getAndroidNotification(notificationMessage: FcmNotificationMessage): AndroidNotification? { + private fun getAndroidNotification(notificationMessage: FcmNotificationMessage): AndroidNotification { + val notification = requireNotNullField(notificationMessage.notification, "Fcm downstream message Notification") + val builder = AndroidNotification.builder() - .setBody(notificationMessage.notification!!.getOrDefault("body", "").toString()) - .setTitle( - notificationMessage.notification!!.getOrDefault("title", "").toString() - ) - .setChannelId( - getString(notificationMessage.notification!!["android_channel_id"]) - ) - .setColor(getString(notificationMessage.notification!!["color"])) - .setTag(getString(notificationMessage.notification!!["tag"])) - .setIcon(getString(notificationMessage.notification!!["icon"])) - .setSound(getString(notificationMessage.notification!!["sound"])) - .setClickAction(getString(notificationMessage.notification!!["click_action"])) - - val bodyLocKey = getString(notificationMessage.notification!!["body_loc_key"]) - val titleLocKey = getString(notificationMessage.notification!!["title_loc_key"]) + .setBody(notification.getOrDefault("body", "").toString()) + .setTitle(notification.getOrDefault("title", "").toString()) + .setChannelId(getString(notification["android_channel_id"])) + .setColor(getString(notification["color"])) + .setTag(getString(notification["tag"])) + .setIcon(getString(notification["icon"])) + .setSound(getString(notification["sound"])) + .setClickAction(getString(notification["click_action"])) + + val bodyLocKey = getString(notification["body_loc_key"]) + val titleLocKey = getString(notification["title_loc_key"]) if (bodyLocKey != null) { builder - .setBodyLocalizationKey( - getString(notificationMessage.notification!!["body_loc_key"]) - ) - .addBodyLocalizationArg( - getString(notificationMessage.notification!!["body_loc_args"]) - ) + .setBodyLocalizationKey(getString(notification["body_loc_key"])) + .addBodyLocalizationArg(getString(notification["body_loc_args"])) } if (titleLocKey != null) { builder - .addTitleLocalizationArg( - getString(notificationMessage.notification!!["title_loc_args"]) - ) - .setTitleLocalizationKey( - getString(notificationMessage.notification!!["title_loc_key"]) - ) + .addTitleLocalizationArg(getString(notification["title_loc_args"])) + .setTitleLocalizationKey(getString(notification["title_loc_key"])) } return builder.build() @@ -171,56 +170,58 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { private fun getApnsConfigBuilder(message: FcmDownstreamMessage, ttl: Duration): ApnsConfig.Builder? { val config = ApnsConfig.builder() - if (message.collapseKey != null) config.putHeader("apns-collapse-id", message.collapseKey) // The date at which the notification is no longer valid. This value is a UNIX epoch // expressed in seconds (UTC). - config.putHeader( - "apns-expiration", - Instant.now().plus(ttl).epochSecond.toString() - ) + config.putHeader("apns-expiration", Instant.now().plus(ttl).epochSecond.toString()) when (message) { is FcmNotificationMessage -> { val notificationMessage = message + val notification = requireNotNullField(notificationMessage.notification, "Fcm downstream message Notification") val apnsData: Map = HashMap(notificationMessage.data ?: emptyMap()) val apsAlertBuilder = ApsAlert.builder() - val title = getString(notificationMessage.notification!!["title"]) + val title = getString(notification["title"]) if (title != null) apsAlertBuilder.setTitle(title) - - val body = getString(notificationMessage.notification!!["body"]) + val body = getString(notification["body"]) if (body != null) apsAlertBuilder.setBody(body) - - val titleLocKey = getString(notificationMessage.notification!!["title_loc_key"]) + val titleLocKey = getString(notification["title_loc_key"]) if (titleLocKey != null) apsAlertBuilder.setTitleLocalizationKey(titleLocKey) - - val titleLocArgs = getString(notificationMessage.notification!!["title_loc_args"]) + val titleLocArgs = getString(notification["title_loc_args"]) if (titleLocKey != null && titleLocArgs != null) apsAlertBuilder.addTitleLocalizationArg(titleLocArgs) - - val bodyLocKey = getString(notificationMessage.notification!!["body_loc_key"]) + val bodyLocKey = getString(notification["body_loc_key"]) if (bodyLocKey != null) apsAlertBuilder.setLocalizationKey(bodyLocKey) - - val bodyLocArgs = getString(notificationMessage.notification!!["body_loc_args"]) + val bodyLocArgs = getString(notification["body_loc_args"]) if (bodyLocKey != null && bodyLocArgs != null) apsAlertBuilder.addLocalizationArg(bodyLocArgs) - val apsBuilder = Aps.builder() - val sound = getString(notificationMessage.notification!!["sound"]) + val sound = getString(notification["sound"]) if (sound != null) apsBuilder.setSound(sound) - - val badge = getString(notificationMessage.notification!!["badge"]) + val badge = getString(notification["badge"]) if (badge != null) apsBuilder.setBadge(badge.toInt()) - - val category = getString(notificationMessage.notification!!["category"]) + val category = getString(notification["category"]) if (category != null) apsBuilder.setCategory(category) - - val threadId = getString(notificationMessage.notification!!["thread_id"]) + val threadId = getString(notification["thread_id"]) if (threadId != null) apsBuilder.setThreadId(threadId) - if (notificationMessage.contentAvailable != null) apsBuilder.setContentAvailable(notificationMessage.contentAvailable!!) + if (notificationMessage.contentAvailable != null) { + apsBuilder.setContentAvailable( + requireNotNullField( + notificationMessage.contentAvailable, + "Fcm downstream message contentAvailable", + ), + ) + } - if (notificationMessage.mutableContent != null) apsBuilder.setMutableContent(notificationMessage.mutableContent!!) + if (notificationMessage.mutableContent != null) { + apsBuilder.setMutableContent( + requireNotNullField( + notificationMessage.mutableContent, + "Fcm downstream message mutableContent", + ), + ) + } return config .putAllCustomData(apnsData) @@ -245,13 +246,12 @@ class AdminSdkFcmSender(options: FirebaseOptions) : FcmSender { } } - private fun getString(obj: Any?): String? { return obj?.toString() } fun getValidTtlMillis(ttl: Int): Duration { - val ttlSeconds = if (ttl >= 0 && ttl <= DEFAULT_TIME_TO_LIVE) ttl else DEFAULT_TIME_TO_LIVE + val ttlSeconds = if (ttl in 0..DEFAULT_TIME_TO_LIVE) ttl else DEFAULT_TIME_TO_LIVE return Duration.ofSeconds(ttlSeconds.toLong()) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDataMessage.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDataMessage.kt index c601aeb9c..67b6ea867 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDataMessage.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDataMessage.kt @@ -16,10 +16,6 @@ package org.radarbase.appserver.jersey.fcm.model -import com.fasterxml.jackson.annotation.JsonProperty - class FcmDataMessage : FcmDownstreamMessage() { - @JsonProperty var data: Map? = null - } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDownstreamMessage.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDownstreamMessage.kt index eb1f59195..8cb6e0ca7 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDownstreamMessage.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmDownstreamMessage.kt @@ -16,39 +16,15 @@ package org.radarbase.appserver.jersey.fcm.model -import com.fasterxml.jackson.annotation.JsonProperty -import jakarta.validation.constraints.NotEmpty - abstract class FcmDownstreamMessage : FcmMessage { - @field:JsonProperty - @field:NotEmpty var to: String? = null - - @field:JsonProperty var condition: String? = null - - @field:JsonProperty("message_id") - @field:NotEmpty var messageId: String? = null - - @field:JsonProperty("collapse_key") var collapseKey: String? = null - - @field:JsonProperty var priority: String? = null - - @field:JsonProperty("content_available") var contentAvailable: Boolean? = null - - @field:JsonProperty("mutable_content") var mutableContent: Boolean? = null - - @field:JsonProperty("time_to_live") var timeToLive: Int? = null - - @field:JsonProperty("delivery_receipt_requested") var deliveryReceiptRequested: Boolean? = null - - @field:JsonProperty("dry_run") var dryRun: Boolean? = null } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmNotificationMessage.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmNotificationMessage.kt index 8482d6373..3fc0ec595 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmNotificationMessage.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/fcm/model/FcmNotificationMessage.kt @@ -16,14 +16,7 @@ package org.radarbase.appserver.jersey.fcm.model -import com.fasterxml.jackson.annotation.JsonProperty - class FcmNotificationMessage : FcmDownstreamMessage() { - - @JsonProperty var notification: Map? = null - - @JsonProperty var data: Map? = null - } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/mapper/ProjectMapper.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/mapper/ProjectMapper.kt index bab465baa..ed5320790 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/mapper/ProjectMapper.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/mapper/ProjectMapper.kt @@ -34,7 +34,7 @@ class ProjectMapper : Mapper { override suspend fun entityToDto(entity: Project): ProjectDto = ProjectDto( id = entity.id, projectId = entity.projectId, - createdAt = entity.createdAt, - updatedAt = entity.updatedAt + createdAt = entity.createdAt?.toInstant(), + updatedAt = entity.updatedAt?.toInstant(), ) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/ProjectRepository.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/ProjectRepository.kt index a5d5c53b4..42ccc90b6 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/ProjectRepository.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/ProjectRepository.kt @@ -16,9 +16,11 @@ package org.radarbase.appserver.jersey.repository +import org.radarbase.appserver.jersey.dto.ProjectDto import org.radarbase.appserver.jersey.entity.Project -interface ProjectRepository: BaseRepository { +interface ProjectRepository : BaseRepository { suspend fun findByProjectId(projectId: String): Project? suspend fun existsByProjectId(projectId: String): Boolean + suspend fun updateEfficiently(dto: ProjectDto): Project } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/TaskRepository.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/TaskRepository.kt index c33fcb357..fd0b99b05 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/TaskRepository.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/TaskRepository.kt @@ -16,7 +16,6 @@ package org.radarbase.appserver.jersey.repository -import jakarta.transaction.Transactional import org.radarbase.appserver.jersey.dto.protocol.AssessmentType import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.search.QuerySpecification diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/DataMessageRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/DataMessageRepositoryImpl.kt index e2e13763a..c80187de2 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/DataMessageRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/DataMessageRepositoryImpl.kt @@ -37,7 +37,8 @@ class DataMessageRepositoryImpl( createQuery( """SELECT COUNT(d) FROM DataMessage d - WHERE d.id = :id """.trimIndent(), + WHERE d.id = :id + """.trimIndent(), Long::class.java, ).setParameter("id", id).singleResult > 0 } @@ -108,7 +109,6 @@ class DataMessageRepositoryImpl( .setParameter("scheduledTime", scheduledTime) .setParameter("ttlSeconds", ttlSeconds) .singleResult > 0 - } override suspend fun existsByIdAndUserId(id: Long, userId: Long): Boolean = transact { @@ -116,7 +116,8 @@ class DataMessageRepositoryImpl( """SELECT COUNT(d) FROM DataMessage d WHERE d.id = :id - AND d.user.id = :userId""".trimIndent(), + AND d.user.id = :userId + """.trimIndent(), Long::class.java, ) .setParameter("id", id) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationRepositoryImpl.kt index 04db770c8..8f9a00bdd 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationRepositoryImpl.kt @@ -88,11 +88,11 @@ class NotificationRepositoryImpl( .resultList } - override suspend fun findByTaskId(taskId: Long): List = transact { + override suspend fun findByTaskId(id: Long): List = transact { createQuery( "SELECT n FROM Notification n WHERE n.task.id = :taskId", Notification::class.java, - ).setParameter("taskId", taskId) + ).setParameter("taskId", id) .resultList } @@ -171,7 +171,8 @@ class NotificationRepositoryImpl( """SELECT COUNT(n) FROM Notification n WHERE n.id = :id - AND n.user.id = :userId""".trimIndent(), + AND n.user.id = :userId + """.trimIndent(), Long::class.java, ).setParameter("id", id) .setParameter("userId", userId) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationStateEventRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationStateEventRepositoryImpl.kt index 2281c2304..a8b9fa1ff 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationStateEventRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/NotificationStateEventRepositoryImpl.kt @@ -36,7 +36,8 @@ class NotificationStateEventRepositoryImpl( createQuery( """SELECT COUNT(n) FROM NotificationStateEvent n - WHERE n.id = :id""".trimIndent(), + WHERE n.id = :id + """.trimIndent(), Long::class.java, ).setParameter("id", id) .singleResult > 0 diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/ProjectRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/ProjectRepositoryImpl.kt index d47ca821c..e29e01c14 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/ProjectRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/ProjectRepositoryImpl.kt @@ -19,8 +19,11 @@ package org.radarbase.appserver.jersey.repository.impl import jakarta.inject.Provider import jakarta.persistence.EntityManager import jakarta.ws.rs.core.Context +import org.radarbase.appserver.jersey.dto.ProjectDto import org.radarbase.appserver.jersey.entity.Project +import org.radarbase.appserver.jersey.exception.InvalidProjectDetailsException import org.radarbase.appserver.jersey.repository.ProjectRepository +import org.radarbase.jersey.exception.HttpNotFoundException import org.radarbase.jersey.hibernate.HibernateRepository import org.radarbase.jersey.service.AsyncCoroutineService @@ -43,7 +46,8 @@ class ProjectRepositoryImpl( createQuery( """SELECT COUNT(p) FROM Project p - WHERE p.id = :id""".trimIndent(), + WHERE p.id = :id + """.trimIndent(), Long::class.java, ).setParameter( "id", id, @@ -54,7 +58,8 @@ class ProjectRepositoryImpl( createQuery( """SELECT COUNT(p) FROM Project p - WHERE p.projectId = :projectId""".trimIndent(), + WHERE p.projectId = :projectId + """.trimIndent(), Long::class.java, ).setParameter( "projectId", projectId, @@ -67,10 +72,44 @@ class ProjectRepositoryImpl( override suspend fun delete(entity: Project) = Unit // Not needed - override suspend fun update(entity: Project): Project? = transact { + override suspend fun update(entity: Project): Project = transact { merge(entity) } + /** + * Efficient way to update the project by retrieving and updating the retrieved persistent project entity Instead of doing it in two transactions. + */ + override suspend fun updateEfficiently(dto: ProjectDto): Project = transact { + val projectId = try { + requireNotNull(dto.id) + } catch (_: IllegalArgumentException) { + throw InvalidProjectDetailsException("The 'id' of the project must be supplied for updating project") + } + + val project = find(Project::class.java, projectId) ?: throw HttpNotFoundException( + "project_not_found", + "Project with id $projectId does not exists. Please create project first", + ) + + val projectExists = createQuery( + """SELECT COUNT(p) + FROM Project p + WHERE p.projectId = :projectId + """.trimIndent(), + Long::class.java, + ).setParameter( + "projectId", dto.projectId, + ).singleResult > 0 + + if (projectExists) { + throw InvalidProjectDetailsException("Project with id $projectId already exists.") + } + + project.apply { + this.projectId = dto.projectId + } + } + override suspend fun findAll(): List = transact { createQuery("SELECT p FROM Project p", Project::class.java).resultList } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskRepositoryImpl.kt index 14b9b2373..9ec20548c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskRepositoryImpl.kt @@ -31,10 +31,10 @@ import org.radarbase.jersey.hibernate.HibernateRepository import org.radarbase.jersey.service.AsyncCoroutineService import java.sql.Timestamp -class TaskRepositoryImpl ( +class TaskRepositoryImpl( @Context em: Provider, @Context asyncCoroutineService: AsyncCoroutineService, -) : HibernateRepository(em, asyncCoroutineService), TaskRepository{ +) : HibernateRepository(em, asyncCoroutineService), TaskRepository { override suspend fun find(id: Long): Task? = transact { find(Task::class.java, id) } @@ -42,7 +42,7 @@ class TaskRepositoryImpl ( override suspend fun exists(id: Long): Boolean = transact { createQuery( "SELECT COUNT(t) FROM Task t WHERE t.id = :id", - Long::class.java + Long::class.java, ).setParameter("id", id) .singleResult > 0 } @@ -67,7 +67,7 @@ class TaskRepositoryImpl ( override suspend fun findByUserId(userId: Long): List = transact { createQuery( "SELECT t FROM Task t WHERE t.user.id = :userId", - Task::class.java + Task::class.java, ).setParameter("userId", userId) .resultList } @@ -78,7 +78,7 @@ class TaskRepositoryImpl ( ): List = transact { createQuery( "SELECT t FROM Task t WHERE t.user.id = :userId AND t.type = :type", - Task::class.java + Task::class.java, ) .setParameter("userId", userId) .setParameter("type", type) @@ -96,7 +96,7 @@ class TaskRepositoryImpl ( type: AssessmentType, ): Unit = transact { createQuery( - "DELETE FROM Task t WHERE t.user.id = :userId AND t.type = :type" + "DELETE FROM Task t WHERE t.user.id = :userId AND t.type = :type", ) .setParameter("userId", userId) .setParameter("type", type) @@ -106,7 +106,7 @@ class TaskRepositoryImpl ( override suspend fun existsByIdAndUserId(id: Long, userId: Long): Boolean = transact { createQuery( "SELECT COUNT(t) FROM Task t WHERE t.id = :id AND t.user.id = :userId", - Long::class.java + Long::class.java, ) .setParameter("id", id) .setParameter("userId", userId) @@ -120,7 +120,7 @@ class TaskRepositoryImpl ( ): Boolean = transact { createQuery( "SELECT COUNT(t) FROM Task t WHERE t.user.id = :userId AND t.name = :name AND t.timestamp = :timestamp", - Long::class.java + Long::class.java, ) .setParameter("userId", userId) .setParameter("name", name) @@ -131,7 +131,7 @@ class TaskRepositoryImpl ( override suspend fun findByIdAndUserId(id: Long, userId: Long): Task? = transact { createQuery( "SELECT t FROM Task t WHERE t.id = :id AND t.user.id = :userId", - Task::class.java + Task::class.java, ) .setParameter("id", id) .setParameter("userId", userId) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskStateEventRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskStateEventRepositoryImpl.kt index 8b4e67cf6..f84205086 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskStateEventRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/TaskStateEventRepositoryImpl.kt @@ -36,7 +36,8 @@ class TaskStateEventRepositoryImpl( createQuery( """SELECT COUNT(e) FROM TaskStateEvent e - WHERE e.id = :id""".trimIndent(), + WHERE e.id = :id + """.trimIndent(), Long::class.java, ).setParameter("id", id) .singleResult > 0 @@ -65,7 +66,6 @@ class TaskStateEventRepositoryImpl( TaskStateEvent::class.java, ).setParameter("taskId", taskId) .resultList - } override suspend fun countByTaskId(taskId: Long): Long = transact { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/UserRepositoryImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/UserRepositoryImpl.kt index 209d60f53..bde5ac624 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/UserRepositoryImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/repository/impl/UserRepositoryImpl.kt @@ -43,7 +43,8 @@ class UserRepositoryImpl( createQuery( """SELECT COUNT(u) FROM User u - WHERE u.id = :id""".trimIndent(), + WHERE u.id = :id + """.trimIndent(), Long::class.java, ).setParameter("id", id).singleResult > 0 } @@ -67,14 +68,14 @@ class UserRepositoryImpl( """SELECT u FROM User u WHERE u.subjectId = :subjectId - AND u.project.id = :projectId""".trimIndent(), + AND u.project.id = :projectId + """.trimIndent(), User::class.java, ) .setParameter("subjectId", subjectId) .setParameter("projectId", projectId) .resultList .firstOrNull() - } override suspend fun findByFcmToken(fcmToken: String): User? = transact { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmDataMessageResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmDataMessageResource.kt new file mode 100644 index 000000000..ddea875b6 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmDataMessageResource.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.inject.Provider +import jakarta.validation.Valid +import jakarta.ws.rs.DELETE +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.PUT +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.dto.fcm.FcmDataMessageDto +import org.radarbase.appserver.jersey.dto.fcm.FcmDataMessages +import org.radarbase.appserver.jersey.service.FcmDataMessageService +import org.radarbase.appserver.jersey.utils.Paths.ALL_KEYWORD +import org.radarbase.appserver.jersey.utils.Paths.MESSAGING_DATA_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.appserver.jersey.utils.tokenForCurrentRequest +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.auth.AuthService +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import java.net.URI +import java.time.LocalDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Suppress("UnresolvedRestParam") +@Path("/") +class FcmDataMessageResource @Inject constructor( + private val fcmDataMessageService: FcmDataMessageService, + private val asyncService: AsyncCoroutineService, + private val authService: AuthService, + private val tokenProvider: Provider, + config: AppserverConfig, +) { + private val requestTimeout: Duration = config.server.requestTimeout.seconds + + @GET + @Path(MESSAGING_DATA_PATH) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getAllDataMessages( + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + Response.ok(fcmDataMessageService.getAllDataMessages()).build() + } + } + + @GET + @Path("$MESSAGING_DATA_PATH/{id}") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getDataMessageUsingId( + @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + Response.ok(fcmDataMessageService.getDataMessageById(id)).build() + } + } + + @GET + @Path("$MESSAGING_DATA_PATH/filter") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getFilteredDataMessages( + @Valid @QueryParam("type") type: String?, + @Valid @QueryParam("delivered") delivered: Boolean?, + @Valid @QueryParam("ttlSeconds") ttlSeconds: Int?, + @Valid @QueryParam("startTime") startTimeStr: String?, + @Valid @QueryParam("endTime") endTimeStr: String?, + @Valid @QueryParam("limit") limit: Int?, + @Suspended asyncResponse: AsyncResponse, + ) { + val startTime = startTimeStr?.let { LocalDateTime.parse(it) } + val endTime = endTimeStr?.let { LocalDateTime.parse(it) } + + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + Response.ok( + fcmDataMessageService.getFilteredDataMessages( + type, + delivered, + ttlSeconds, + startTime, + endTime, + limit, + ), + ).build() + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun getDataMessageUsingProjectIdAndSubjectId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.getDataMessagesByProjectIdAndSubjectId(projectId, subjectId).let { + Response.ok(it).build() + } + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$MESSAGING_DATA_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getDataMessageUsingProjectId( + @Valid @PathParam("projectId") projectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = projectId, subject = token.subject), + token, + ) + fcmDataMessageService.getDataMessagesByProjectId(projectId).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun addSingleDataMessage( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid fcmDataMessage: FcmDataMessageDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.addDataMessage( + fcmDataMessage, + subjectId, + projectId, + ).let { + Response.created(URI("$MESSAGING_DATA_PATH/${it.id}")).entity(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH/batch") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun addBatchDataMessages( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid fcmDataMessages: FcmDataMessages, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.addDataMessages( + fcmDataMessages, + subjectId, + projectId, + ).let { + Response.ok().build() + } + } + } + + @PUT + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun updateDataMessage( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid fcmDataMessage: FcmDataMessageDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.updateDataMessage( + fcmDataMessage, + subjectId, + projectId, + ).let { + Response.ok(it).build() + } + } + } + + @DELETE + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH/$ALL_KEYWORD") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun deleteDataMessageForUser( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.removeDataMessagesForUser(projectId, subjectId).let { + Response.ok().build() + } + } + } + + @DELETE + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_DATA_PATH/{id}") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun deleteDataMessageUsingProjectIdAndSubjectIdAndDataMessageId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmDataMessageService.deleteDataMessageByProjectIdAndSubjectIdAndDataMessageId( + projectId, + subjectId, + id, + ) + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmNotificationResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmNotificationResource.kt new file mode 100644 index 000000000..3a9a8786e --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/FcmNotificationResource.kt @@ -0,0 +1,308 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Provider +import jakarta.validation.Valid +import jakarta.ws.rs.DELETE +import jakarta.ws.rs.DefaultValue +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.PUT +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.dto.fcm.FcmNotificationDto +import org.radarbase.appserver.jersey.service.FcmNotificationService +import org.radarbase.appserver.jersey.utils.Paths.ALL_KEYWORD +import org.radarbase.appserver.jersey.utils.Paths.MESSAGING_NOTIFICATION_PATH +import org.radarbase.appserver.jersey.utils.Paths.NOTIFICATION_ID +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.appserver.jersey.utils.tokenForCurrentRequest +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.auth.AuthService +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import java.net.URI +import java.time.LocalDateTime +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Suppress("UnresolvedRestParam") +@Path("/") +class FcmNotificationResource( + private val asyncService: AsyncCoroutineService, + private val authService: AuthService, + private val tokenProvider: Provider, + private val fcmNotificationService: FcmNotificationService, + config: AppserverConfig, +) { + private val requestTimeout: Duration = config.server.requestTimeout.seconds + + @GET + @Path(MESSAGING_NOTIFICATION_PATH) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getAllNotifications( + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.getAllNotifications().let { + Response.ok(it).build() + } + } + } + + @GET + @Path("$MESSAGING_NOTIFICATION_PATH/{id}") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE) + fun getNotificationUsingId( + @Valid @PathParam("id") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.getNotificationById(id).let { + Response.ok(it).build() + } + } + } + + @GET + @Path("$MESSAGING_NOTIFICATION_PATH/filter") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getFilteredNotifications( + @Valid @QueryParam("type") type: String?, + @Valid @QueryParam("delivered") delivered: Boolean?, + @Valid @QueryParam("ttlSeconds") ttlSeconds: Int?, + @Valid @QueryParam("startTime") startTimeStr: String?, + @Valid @QueryParam("endTime") endTimeStr: String?, + @Valid @QueryParam("limit") limit: Int?, + @Suspended asyncResponse: AsyncResponse, + ) { + val startTime = startTimeStr?.let { LocalDateTime.parse(it) } + val endTime = endTimeStr?.let { LocalDateTime.parse(it) } + + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + Response.ok( + fcmNotificationService.getFilteredNotifications( + type, + delivered, + ttlSeconds, + startTime, + endTime, + limit, + ), + ).build() + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun getNotificationsUsingProjectIdAndSubjectId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.getNotificationsByProjectIdAndSubjectId(projectId, subjectId).let { + Response.ok(it).build() + } + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$MESSAGING_NOTIFICATION_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId") + fun getNotificationsUsingProjectId( + @Valid @PathParam("projectId") projectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = projectId, subject = token.subject), + token, + ) + fcmNotificationService.getNotificationsByProjectId(projectId).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun addSingleNotification( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid fcmNotification: FcmNotificationDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.addNotification( + fcmNotification, + subjectId, + projectId, + ).let { + Response.created(URI("$MESSAGING_NOTIFICATION_PATH/${it.id}")).entity(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/schedule") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun scheduleUserNotifications( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.scheduleAllUserNotifications(subjectId, projectId).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/$NOTIFICATION_ID/schedule") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun scheduleUserNotification( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid @PathParam("notificationId") notificationId: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.scheduleNotification(subjectId, projectId, notificationId).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/batch") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun addBatchNotifications( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @QueryParam("schedule") @DefaultValue("false") schedule: Boolean, + @Valid fcmNotification: FcmNotificationDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.addNotification( + fcmNotification, + subjectId, + projectId, + schedule, + ).let { + Response.ok().build() + } + } + } + + @PUT + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun updateNotification( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Valid fcmNotification: FcmNotificationDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.updateNotification( + fcmNotification, + subjectId, + projectId, + ).let { + Response.ok(it).build() + } + } + } + + @DELETE + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/$ALL_KEYWORD") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun deleteNotificationsForUser( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.removeNotificationsForUser(projectId, subjectId).let { + Response.ok().build() + } + } + } + + @DELETE + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/$NOTIFICATION_ID") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun deleteNotificationUsingProjectIdAndSubjectIdAndNotificationId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @PathParam("notificationId") id: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + fcmNotificationService.deleteNotificationByProjectIdAndSubjectIdAndNotificationId( + projectId, + subjectId, + id, + ) + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/GithubResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/GithubResource.kt new file mode 100644 index 000000000..9eb243455 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/GithubResource.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.service.github.GithubService +import org.radarbase.appserver.jersey.utils.Paths.GITHUB_CONTENT_PATH +import org.radarbase.appserver.jersey.utils.Paths.GITHUB_PATH +import org.radarbase.auth.authorization.Permission +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import java.net.MalformedURLException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Path("/$GITHUB_PATH") +class GithubResource @Inject constructor( + private val githubService: GithubService, + private val asyncService: AsyncCoroutineService, + appserverConfig: AppserverConfig, +) { + private val requestTimeout: Duration = appserverConfig.server.requestTimeout.seconds + + @GET + @Path("/$GITHUB_CONTENT_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getGithubContent( + @QueryParam("url") url: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + try { + Response.ok(githubService.getGithubContent(url)).build() + } catch (ex: MalformedURLException) { + Response.status(Response.Status.BAD_REQUEST).entity(ex.message).build() + } catch (ex: Exception) { + Response.status(Response.Status.BAD_GATEWAY).entity( + "Error while fetching content from github: ${ex.message}", + ).build() + } + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/NotificationStateEventResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/NotificationStateEventResource.kt new file mode 100644 index 000000000..0acafb216 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/NotificationStateEventResource.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.dto.NotificationStateEventDto +import org.radarbase.appserver.jersey.service.NotificationStateEventService +import org.radarbase.appserver.jersey.utils.Paths.MESSAGING_NOTIFICATION_PATH +import org.radarbase.appserver.jersey.utils.Paths.NOTIFICATION_ID +import org.radarbase.appserver.jersey.utils.Paths.NOTIFICATION_STATE_EVENTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.auth.authorization.Permission +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import kotlin.time.Duration.Companion.seconds + +@Suppress("UnresolvedRestParam") +@Path("/") +class NotificationStateEventResource @Inject constructor( + private val notificationStateEventService: NotificationStateEventService, + private val asyncService: AsyncCoroutineService, + appserverConfig: AppserverConfig, +) { + private val requestTimeout = appserverConfig.server.requestTimeout.seconds + + @GET + @Path("/$MESSAGING_NOTIFICATION_PATH/$NOTIFICATION_ID/$NOTIFICATION_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getNotificationStateEventsByNotificationId( + @PathParam("notificationId") notificationId: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + notificationStateEventService.getNotificationStateEventsByNotificationId(notificationId).let { + Response.ok(it).build() + } + } + } + + @GET + @Path("/$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/$NOTIFICATION_ID/$NOTIFICATION_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun getNotificationStateEvents( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @PathParam("notificationId") notificationId: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + notificationStateEventService.getNotificationStateEvents( + projectId, + subjectId, + notificationId, + ).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("/$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$MESSAGING_NOTIFICATION_PATH/$NOTIFICATION_ID/$NOTIFICATION_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun postNotificationStateEvent( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @PathParam("notificationId") notificationId: Long, + notificationStateEventDto: NotificationStateEventDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + notificationStateEventService.publishNotificationStateEventExternal( + projectId, + subjectId, + notificationId, + notificationStateEventDto, + ) + notificationStateEventService.getNotificationStateEvents( + projectId, + subjectId, + notificationId, + ).let { + Response.ok(it).build() + } + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProjectResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProjectResource.kt index 8eb375f1f..b9b880b53 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProjectResource.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProjectResource.kt @@ -17,93 +17,168 @@ package org.radarbase.appserver.jersey.resource import jakarta.inject.Inject +import jakarta.inject.Provider import jakarta.validation.Valid -import jakarta.ws.rs.* +import jakarta.ws.rs.Consumes +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.PUT +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam import jakarta.ws.rs.container.AsyncResponse import jakarta.ws.rs.container.Suspended -import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON import jakarta.ws.rs.core.Response +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.toList +import org.radarbase.appserver.jersey.config.AppserverConfig import org.radarbase.appserver.jersey.dto.ProjectDto +import org.radarbase.appserver.jersey.dto.ProjectDtos import org.radarbase.appserver.jersey.service.ProjectService +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.tokenForCurrentRequest +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.auth.AuthService +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission import org.radarbase.jersey.service.AsyncCoroutineService -import org.slf4j.LoggerFactory import java.net.URI +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -@Path("") +@Suppress("UnresolvedRestParam") +@Path("/") class ProjectResource @Inject constructor( private val projectService: ProjectService, private val asyncService: AsyncCoroutineService, + private val authService: AuthService, + private val tokenProvider: Provider, + config: AppserverConfig, ) { + private val requestTimeout: Duration = config.server.requestTimeout.seconds - @GET - @Path("/projects") - @Produces(MediaType.APPLICATION_JSON) - fun getAllProjects( + @POST + @Path(PROJECTS_PATH) + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun addProject( + @Valid projectDto: ProjectDto, @Suspended asyncResponse: AsyncResponse, ) { - asyncService.runAsCoroutine(asyncResponse) { - projectService.getAllProjects().run { - Response.ok(this).build() + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = projectDto.projectId, subject = token.subject), + token, + ) + projectService.addProject(projectDto).let { + Response + .created(URI("/projects/project?id=${projectDto.id}")) + .entity(it) + .build() } } } - @POST - @Path("/projects") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - fun addProject( - project: ProjectDto, + @PUT + @Path("$PROJECTS_PATH/$PROJECT_ID") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE) + fun updateProject( + @Valid @PathParam("projectId") projectId: String, + @Valid projectDto: ProjectDto, @Suspended asyncResponse: AsyncResponse, ) { - asyncService.runAsCoroutine(asyncResponse) { - projectService.addProject(project).run { - Response.created(URI("/projects")).entity(this).build() + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_UPDATE, + EntityDetails(project = projectId, subject = token.subject), + token, + ) + projectService.updateProject(projectDto).let { + Response.ok(it).build() } } } - @PUT - @Path("/projects/{projectId}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - fun updateProject( - @Valid @PathParam("projectId") projectId: String, - @Valid project: ProjectDto, + @GET + @Path(PROJECTS_PATH) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getAllProjects( @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - projectService.updateProject(project).let(Response::ok).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + projectService.getAllProjects().projects + .asFlow() + .filter { + authService.hasPermission( + Permission.PROJECT_READ, + EntityDetails(project = it.projectId), + tokenForCurrentRequest(asyncService, tokenProvider), + ) + }.toList() + .toMutableList() + .let { + ProjectDtos(it) + }.let { + Response.ok(it).build() + } + } } @GET - @Path("/projects/project") - @Produces(MediaType.APPLICATION_JSON) + @Path("$PROJECTS_PATH/project") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) fun getProjectUsingId( @QueryParam("id") id: Long, @Suspended asyncResponse: AsyncResponse, ) { - asyncService.runAsCoroutine(asyncResponse) { - projectService.getProjectById(id).let { - Response.ok(it).build() - } + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val project = projectService.getProjectById(id) + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.PROJECT_READ, + EntityDetails(project = project.projectId), + token, + ) + Response.ok(project).build() } } @GET - @Path("/projects/{projectId}") - @Produces(MediaType.APPLICATION_JSON) - fun getProjectByProjectId( + @Path("$PROJECTS_PATH/$PROJECT_ID") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getProjectUsingProjectId( @Valid @PathParam("projectId") projectId: String, @Suspended asyncResponse: AsyncResponse, ) { - asyncService.runAsCoroutine(asyncResponse) { - projectService.getProjectByProjectId(projectId).let { - Response.ok(it).build() - } + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val project = projectService.getProjectByProjectId(projectId) + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = project.projectId, subject = token.subject), + token, + ) + Response.ok(project).build() } } - - companion object { - private val logger = LoggerFactory.getLogger(ProjectResource::class.java) - } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProtocolResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProtocolResource.kt new file mode 100644 index 000000000..f53230add --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/ProtocolResource.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.validation.Valid +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.service.github.protocol.ProtocolGenerator +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.PROTOCOLS_PATH +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.auth.authorization.Permission +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import kotlin.time.Duration.Companion.seconds + +@Suppress("UnresolvedRestParam") +@Path("/") +class ProtocolResource @Inject constructor( + private val protocolGenerator: ProtocolGenerator, + private val asyncService: AsyncCoroutineService, + appserverConfig: AppserverConfig, +) { + private val requestTimeout = appserverConfig.server.requestTimeout.seconds + + @GET + @Path(PROTOCOLS_PATH) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ) + fun getProtocols( + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + protocolGenerator.retrieveAllProtocols() + } + } + + @Suppress("UNUSED_PARAMETER") + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$PROTOCOLS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun getProtocolsUsingProjectIdAndSubjectId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + protocolGenerator.getProtocolForSubject(subjectId) + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$PROTOCOLS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.PROJECT_READ, projectPathParam = "projectId") + fun getProtocolsUsingProjectId( + @Valid @PathParam("projectId") projectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + try { + protocolGenerator.getProtocol(projectId).let { + Response.ok(it).build() + } + } catch (ex: Exception) { + Response.status(Response.Status.BAD_GATEWAY).entity(ex.message).build() + } + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/QuestionnaireScheduleResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/QuestionnaireScheduleResource.kt new file mode 100644 index 000000000..fcae16642 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/QuestionnaireScheduleResource.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("UnresolvedRestParam") + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.validation.Valid +import jakarta.ws.rs.DELETE +import jakarta.ws.rs.DefaultValue +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.PUT +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.dto.protocol.Assessment +import org.radarbase.appserver.jersey.dto.protocol.AssessmentType +import org.radarbase.appserver.jersey.service.questionnaire.schedule.QuestionnaireScheduleService +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.QUESTIONNAIRE_SCHEDULE +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.auth.authorization.Permission +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import java.net.URI +import java.time.Instant +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Path("/") +class QuestionnaireScheduleResource @Inject constructor( + private val scheduleService: QuestionnaireScheduleService, + private val asyncService: AsyncCoroutineService, + appserverConfig: AppserverConfig, +) { + private val requestTimeout: Duration = appserverConfig.server.requestTimeout.seconds + + @POST + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE") + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun generateScheduleUsingProjectIdAndSubjectId( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + try { + scheduleService.generateScheduleUsingProjectIdAndSubjectId( + projectId, + subjectId, + ) + Response.created( + URI("$PROJECTS_PATH/$projectId/$USERS_PATH/$subjectId/$QUESTIONNAIRE_SCHEDULE"), + ).build() + } catch (ex: Exception) { + Response.status(Response.Status.BAD_REQUEST).entity( + "Error while generating schedule: ${ex.message}", + ) + } + } + } + + @PUT + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE") + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun generateScheduleUsingProtocol( + @Valid assessment: Assessment, + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + try { + scheduleService.generateScheduleUsingProjectIdAndSubjectIdAndAssessment( + projectId, + subjectId, + assessment, + ) + Response.created( + URI("$PROJECTS_PATH/$projectId/$USERS_PATH/$subjectId/$QUESTIONNAIRE_SCHEDULE"), + ).build() + } catch (ex: Exception) { + Response.status(Response.Status.BAD_REQUEST).entity( + "Error while generating schedule: ${ex.message}", + ) + } + } + } + + @GET + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE") + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun getScheduleUsingProjectIdAndSubjectId( + @Valid @PathParam("projectId") projectId: String, + @Valid @PathParam("subjectId") subjectId: String, + @QueryParam("type") @DefaultValue("all") type: String, + @QueryParam("search") @DefaultValue("") search: String, + @QueryParam("startTime") startTimeStr: String?, + @QueryParam("endTime") endTimeStr: String?, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val startTime: Instant? = startTimeStr?.let { Instant.parse(it) } + val endTime: Instant? = endTimeStr?.let { Instant.parse(it) } + val assessmentType = AssessmentType.valueOf(type.uppercase(Locale.getDefault())) + + var sent = false + if (startTime != null && endTime != null) { + scheduleService.getTasksForDateUsingProjectIdAndSubjectId( + projectId, + subjectId, + startTime, + endTime, + ).let { + sent = true + Response.ok(it).build() + } + } + + if (assessmentType != AssessmentType.ALL) { + sent = true + Response.ok( + scheduleService.getTasksByTypeUsingProjectIdAndSubjectId( + projectId, + subjectId, + assessmentType, + search, + ), + ).build() + } + + if (!sent) { + Response.ok( + scheduleService.getTasksUsingProjectIdAndSubjectId( + projectId, + subjectId, + ), + ).build() + } + } + } + + @DELETE + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE") + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") + fun deleteScheduleForUser( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @QueryParam("type") @DefaultValue("all") type: String, + @QueryParam("search") @DefaultValue("") search: String, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val assessmentType = AssessmentType.valueOf(type.uppercase(Locale.getDefault())) + scheduleService.removeScheduleForUserUsingSubjectIdAndType( + projectId, + subjectId, + assessmentType, + search, + ) + Response.ok().build() + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/TaskStateEventResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/TaskStateEventResource.kt new file mode 100644 index 000000000..efae2f6d9 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/TaskStateEventResource.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.resource + +import jakarta.inject.Inject +import jakarta.ws.rs.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.container.AsyncResponse +import jakarta.ws.rs.container.Suspended +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON +import jakarta.ws.rs.core.Response +import org.radarbase.appserver.jersey.config.AppserverConfig +import org.radarbase.appserver.jersey.dto.TaskStateEventDto +import org.radarbase.appserver.jersey.service.TaskStateEventService +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.QUESTIONNAIRE_SCHEDULE +import org.radarbase.appserver.jersey.utils.Paths.QUESTIONNAIRE_STATE_EVENTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.TASK_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.auth.authorization.Permission +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission +import org.radarbase.jersey.service.AsyncCoroutineService +import kotlin.time.Duration.Companion.seconds + +@Suppress("UnresolvedRestParam") +@Path("/") +class TaskStateEventResource @Inject constructor( + private val taskStateEventService: TaskStateEventService, + private val asyncService: AsyncCoroutineService, + appserverConfig: AppserverConfig, +) { + private val requestTimeout = appserverConfig.server.requestTimeout.seconds + + @GET + @Path("/$QUESTIONNAIRE_SCHEDULE/$TASK_ID/$QUESTIONNAIRE_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE) + fun getTaskStateEventsByTaskId( + @PathParam("taskId") taskId: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + taskStateEventService.getTaskStateEventsByTaskId(taskId).let { + Response.ok(it).build() + } + } + } + + @GET + @Path("/$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE/$TASK_ID/$QUESTIONNAIRE_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun getTaskStateEvents( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @PathParam("taskId") taskId: Long, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + taskStateEventService.getTaskStateEvents(projectId, subjectId, taskId).let { + Response.ok(it).build() + } + } + } + + @POST + @Path("/$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID/$QUESTIONNAIRE_SCHEDULE/$TASK_ID/$QUESTIONNAIRE_STATE_EVENTS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") + fun postTaskStateEvents( + @PathParam("projectId") projectId: String, + @PathParam("subjectId") subjectId: String, + @PathParam("taskId") taskId: Long, + taskStateEventDto: TaskStateEventDto, + @Suspended asyncResponse: AsyncResponse, + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + taskStateEventService.publishNotificationStateEventExternal( + projectId, + subjectId, + taskId, + taskStateEventDto, + ) + + taskStateEventService.getTaskStateEvents(projectId, subjectId, taskId).let { + Response.ok(it).build() + } + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/UserResource.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/UserResource.kt index c9cc25446..8f4886632 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/UserResource.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/resource/UserResource.kt @@ -17,9 +17,9 @@ package org.radarbase.appserver.jersey.resource import jakarta.inject.Inject +import jakarta.inject.Provider import jakarta.validation.Valid import jakarta.ws.rs.Consumes -import jakarta.ws.rs.DELETE import jakarta.ws.rs.DefaultValue import jakarta.ws.rs.GET import jakarta.ws.rs.POST @@ -30,126 +30,198 @@ import jakarta.ws.rs.Produces import jakarta.ws.rs.QueryParam import jakarta.ws.rs.container.AsyncResponse import jakarta.ws.rs.container.Suspended -import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.MediaType.APPLICATION_JSON import jakarta.ws.rs.core.Response +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.toList +import org.radarbase.appserver.jersey.config.AppserverConfig import org.radarbase.appserver.jersey.dto.fcm.FcmUserDto +import org.radarbase.appserver.jersey.dto.fcm.FcmUsers import org.radarbase.appserver.jersey.service.UserService +import org.radarbase.appserver.jersey.utils.Paths.PROJECTS_PATH +import org.radarbase.appserver.jersey.utils.Paths.PROJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.SUBJECT_ID +import org.radarbase.appserver.jersey.utils.Paths.USERS_PATH +import org.radarbase.appserver.jersey.utils.tokenForCurrentRequest +import org.radarbase.auth.authorization.EntityDetails +import org.radarbase.auth.authorization.Permission +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.auth.AuthService +import org.radarbase.jersey.auth.Authenticated +import org.radarbase.jersey.auth.NeedsPermission import org.radarbase.jersey.service.AsyncCoroutineService import java.net.URI +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds -@Path("") +@Suppress("UnresolvedRestParam") +@Path("/") class UserResource @Inject constructor( private val userService: UserService, private val asyncService: AsyncCoroutineService, + private val authService: AuthService, + private val tokenProvider: Provider, + config: AppserverConfig, ) { + private val requestTimeout: Duration = config.server.requestTimeout.seconds @POST - @Path("projects/{projectId}/users") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE) fun addUserToProject( - @Valid userDto: FcmUserDto, + @Valid fcmUserDto: FcmUserDto, @Valid @PathParam("projectId") projectId: String, @QueryParam("forceFcmToken") @DefaultValue("false") forceFcmToken: Boolean, @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userDto.projectId = projectId - if (forceFcmToken) userService.checkFcmTokenExistsAndReplace(userDto) - val user = userService.saveUserInProject(userDto) - Response.created(URI("/users/user?id=${user.id}")).entity(user).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_UPDATE, + EntityDetails(project = projectId, subject = token.subject), + token, + ) + if (forceFcmToken) userService.checkFcmTokenExistsAndReplace(fcmUserDto) + userService.saveUserInProject(fcmUserDto).let { + Response.created(URI("/projects/$projectId/users/?id=${it.id}")).entity(it).build() + } + } } @PUT - @Path("projects/{projectId}/users/{subjectId}") - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID") + @Consumes(APPLICATION_JSON) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_UPDATE, projectPathParam = "projectId", userPathParam = "subjectId") fun updateUserInProject( + @Valid userDto: FcmUserDto, @Valid @PathParam("projectId") projectId: String, @Valid @PathParam("subjectId") subjectId: String, - @Valid userDto: FcmUserDto, + @QueryParam("forceFcmToken") @DefaultValue("false") forceFcmToken: Boolean, @Suspended asyncResponse: AsyncResponse, - @QueryParam("forceFcmToken") forceFcmToken: Boolean = false, - ) = asyncService.runAsCoroutine(asyncResponse) { - userDto.apply { - this.subjectId = subjectId - this.projectId = projectId + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + userDto.apply { + this.subjectId = subjectId + this.projectId = projectId + } + if (forceFcmToken) userService.checkFcmTokenExistsAndReplace(userDto) + userService.updateUser(userDto).let { + Response.ok(it).build() + } } - if (forceFcmToken) userService.checkFcmTokenExistsAndReplace(userDto) - val user = userService.updateUser(userDto) - Response.ok(user).build() } @GET - @Path("users") - @Produces(MediaType.APPLICATION_JSON) - fun getAllUsers( + @Path(USERS_PATH) + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getAllRadarUsers( @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userService.getAllRadarUsers().let { - Response.ok(it).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + userService.getAllRadarUsers() + .users + .asFlow() + .filter { + authService.hasPermission( + Permission.SUBJECT_READ, + EntityDetails(project = it.projectId, subject = it.subjectId), + tokenForCurrentRequest(asyncService, tokenProvider), + ) + } + .toList() + .toMutableList().let { + FcmUsers(it) + }.let { + Response.ok(it).build() + } } } @GET - @Path("users/user") - @Produces(MediaType.APPLICATION_JSON) - fun getUserById( + @Path("$USERS_PATH/user") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getRadarUserUsingId( @QueryParam("id") id: Long, @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userService.getUserById(id).let { - Response.ok(it).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val user: FcmUserDto = userService.getUserById(id) + fcmUserDtoAsResponseIfAuthorized(user) } } @GET - @Path("users/{subjectId}") - @Produces(MediaType.APPLICATION_JSON) - fun getUserBySubjectId( + @Path("$USERS_PATH/$SUBJECT_ID") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) + fun getRadarUserUsingSubjectId( @PathParam("subjectId") subjectId: String, @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userService.getUserBySubjectId(subjectId).let { - Response.ok(it).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val user: FcmUserDto = userService.getUserBySubjectId(subjectId) + fcmUserDtoAsResponseIfAuthorized(user) } } + suspend fun fcmUserDtoAsResponseIfAuthorized( + fcmUserDto: FcmUserDto, + ): Response { + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = fcmUserDto.projectId, subject = fcmUserDto.subjectId), + tokenForCurrentRequest(asyncService, tokenProvider), + ) + + return Response.ok(fcmUserDto).build() + } @GET - @Path("projects/{projectId}/users") - @Produces(MediaType.APPLICATION_JSON) + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ) fun getUsersUsingProjectId( @Valid @PathParam("projectId") projectId: String, @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userService.getUsersByProjectId(projectId).let { - Response.ok(it).build() + ) { + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + val users = userService.getUsersByProjectId(projectId) + val token = tokenForCurrentRequest(asyncService, tokenProvider) + authService.checkPermission( + Permission.SUBJECT_READ, + EntityDetails(project = projectId, subject = token.subject), + token, + ) + Response.ok(users).build() } } @GET - @Path("projects/{projectId}/users/{subjectId}") - @Produces(MediaType.APPLICATION_JSON) + @Path("$PROJECTS_PATH/$PROJECT_ID/$USERS_PATH/$SUBJECT_ID") + @Produces(APPLICATION_JSON) + @Authenticated + @NeedsPermission(Permission.SUBJECT_READ, projectPathParam = "projectId", userPathParam = "subjectId") fun getUsersUsingProjectIdAndSubjectId( @Valid @PathParam("projectId") projectId: String, @Valid @PathParam("subjectId") subjectId: String, @Suspended asyncResponse: AsyncResponse, - ) = asyncService.runAsCoroutine(asyncResponse) { - userService.getUserByProjectIdAndSubjectId(projectId, subjectId).let { - Response.ok(it).build() - } - } - - @DELETE - @Path("projects/{projectId}/users/{subjectId}") - fun deleteUserUsingProjectIdAndSubjectId( - @PathParam("projectId") projectId: String, - @PathParam("subjectId") subjectId: String, - @Suspended asyncResponse: AsyncResponse, ) { - asyncService.runAsCoroutine(asyncResponse) { - userService.deleteUserByProjectIdAndSubjectId(projectId, subjectId) - Response.ok().build() + asyncService.runAsCoroutine(asyncResponse, requestTimeout) { + userService.getUserByProjectIdAndSubjectId(projectId, subjectId).let { + Response.ok(it).build() + } } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/SearchCriteria.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/SearchCriteria.kt index 2f437a0f7..1dc83ef99 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/SearchCriteria.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/SearchCriteria.kt @@ -19,7 +19,7 @@ package org.radarbase.appserver.jersey.search data class SearchCriteria( val key: String, val operation: String, - val value: Any + val value: Any, ) { /*** * Only AND supported in the first instance. Later we can add a new query param that can provide this value diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecification.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecification.kt index b0339a004..087d6a5d7 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecification.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecification.kt @@ -22,9 +22,10 @@ import jakarta.persistence.criteria.Predicate import jakarta.persistence.criteria.Root import org.radarbase.appserver.jersey.entity.Task +@Suppress("UNCHECKED_CAST") class TaskSpecification( - private val searchCriteria: SearchCriteria -) : QuerySpecification{ + private val searchCriteria: SearchCriteria, +) : QuerySpecification { override fun toPredicate( root: Root, @@ -35,18 +36,18 @@ class TaskSpecification( return when (searchCriteria.operation) { ">" -> criteriaBuilder.greaterThanOrEqualTo( root.get(searchCriteria.key), - searchCriteria.value as Comparable + searchCriteria.value as Comparable, ) "<" -> criteriaBuilder.lessThanOrEqualTo( root.get(searchCriteria.key), - searchCriteria.value as Comparable + searchCriteria.value as Comparable, ) ":" -> if (path.javaType == String::class.java) { criteriaBuilder.like( root.get(searchCriteria.key), - "%${searchCriteria.value}%" + "%${searchCriteria.value}%", ) } else { criteriaBuilder.equal(path, searchCriteria.value) @@ -54,6 +55,5 @@ class TaskSpecification( else -> throw IllegalArgumentException("Unknown operation: ${searchCriteria.operation}") } - } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecificationsBuilder.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecificationsBuilder.kt index 9dacf1bb0..475193e0c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecificationsBuilder.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/search/TaskSpecificationsBuilder.kt @@ -18,6 +18,7 @@ package org.radarbase.appserver.jersey.search import jakarta.persistence.criteria.CriteriaBuilder import jakarta.persistence.criteria.CriteriaQuery +import jakarta.persistence.criteria.Predicate import jakarta.persistence.criteria.Root import org.radarbase.appserver.jersey.entity.Task @@ -33,7 +34,9 @@ class TaskSpecificationsBuilder { return QuerySpecification { root: Root, query: CriteriaQuery<*>, builder: CriteriaBuilder -> if (params.isEmpty()) return@QuerySpecification builder.conjunction() - val predicates = params.map { TaskSpecification(it).toPredicate(root, query, builder) } + val predicates: List = params.map { + TaskSpecification(it).toPredicate(root, query, builder) + } var result = predicates.first() for (i in 1 until predicates.size) { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/Base64AsStringSerializer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/Base64AsStringSerializer.kt new file mode 100644 index 000000000..33337f1fc --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/Base64AsStringSerializer.kt @@ -0,0 +1,30 @@ +package org.radarbase.appserver.jersey.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.Base64 + +object Base64AsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base64AsString", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: String) { + // when writing JSON, we must re-encode into Base64 + val encoded = Base64.getEncoder().encodeToString(value.toByteArray()) + encoder.encodeString(encoded) + } + + override fun deserialize(decoder: Decoder): String { + val raw = decoder.decodeString().replace(Regex("[\n\r]"), "") + return try { + val decodedBytes = Base64.getDecoder().decode(raw) + String(decodedBytes) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid base64 value: $raw", e) + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/ReferenceTimestampSerializer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/ReferenceTimestampSerializer.kt new file mode 100644 index 000000000..48a3467ac --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/ReferenceTimestampSerializer.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.radarbase.appserver.jersey.dto.protocol.ReferenceTimestamp +import org.radarbase.appserver.jersey.dto.protocol.ReferenceTimestampType + +object ReferenceTimestampSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor( + "ReferenceTimestamp", + ) + + override fun serialize(encoder: Encoder, value: ReferenceTimestamp) { + encoder.encodeString(value.timestamp.toString()) + } + + override fun deserialize(decoder: Decoder): ReferenceTimestamp { + val input = decoder as? JsonDecoder + ?: throw IllegalStateException("Only works with JSON") + + val element = input.decodeJsonElement() + return when (element) { + is JsonObject -> { + input.json.decodeFromJsonElement(ReferenceTimestamp.serializer(), element) + } + is JsonPrimitive -> { + ReferenceTimestamp(element.content, ReferenceTimestampType.DATETIMEUTC) + } + else -> throw IllegalArgumentException("Unexpected JSON for ReferenceTimestamp: $element") + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/URISerializer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/URISerializer.kt new file mode 100644 index 000000000..5f3e204e8 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/serialization/URISerializer.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.net.URI + +object URISerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "java.net.URI", + PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: URI) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): URI { + return URI(decoder.decodeString()) + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/DataMessageStateEventService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/DataMessageStateEventService.kt new file mode 100644 index 000000000..cf91f49d7 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/DataMessageStateEventService.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.service + +import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.glassfish.hk2.api.ServiceLocator +import org.radarbase.appserver.jersey.dto.DataMessageStateEventDto +import org.radarbase.appserver.jersey.entity.DataMessage +import org.radarbase.appserver.jersey.entity.DataMessageStateEvent +import org.radarbase.appserver.jersey.event.state.MessageState +import org.radarbase.appserver.jersey.repository.DataMessageStateEventRepository +import java.io.IOException + +@Suppress("unused") +class DataMessageStateEventService @Inject constructor( + private val dataMessageStateEventRepository: DataMessageStateEventRepository, + private val dataMessageService: FcmDataMessageService, + private val serviceLocator: ServiceLocator, +) { + private var dataMessageEventBus: EventBus? = null + get() { + if (field == null) { + return serviceLocator.getService(EventBus::class.java) + ?.also { field = it } + } + return field + } + + suspend fun addDataMessageStateEvent(dataMessageStateEvent: DataMessageStateEvent) { + dataMessageStateEventRepository.add(dataMessageStateEvent) + } + + suspend fun getDataMessageStateEvents( + projectId: String, + subjectId: String, + dataMessageId: Long, + ): List { + dataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId( + projectId, + subjectId, + dataMessageId, + ) + + val stateEvents: List = + dataMessageStateEventRepository.findByDataMessageId(dataMessageId) + return stateEvents.map { stateEvent: DataMessageStateEvent -> + DataMessageStateEventDto( + stateEvent.id, + nonNullDataMessage(stateEvent).id, + stateEvent.state, + stateEvent.time, + stateEvent.associatedInfo, + ) + } + } + + suspend fun getDataMessageStateEventsByDataMessageId( + dataMessageId: Long, + ): List { + val stateEvents: List = + dataMessageStateEventRepository.findByDataMessageId(dataMessageId) + return stateEvents.map { stateEvent: DataMessageStateEvent -> + DataMessageStateEventDto( + stateEvent.id, + nonNullDataMessage(stateEvent).id, + stateEvent.state, + stateEvent.time, + stateEvent.associatedInfo, + ) + } + } + + suspend fun publishDataMessageStateEventExternal( + projectId: String, + subjectId: String, + dataMessageId: Long, + dataMessageStateEventDto: DataMessageStateEventDto, + ) { + checkState(dataMessageId, dataMessageStateEventDto.state) + val dataMessage = dataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId( + projectId, + subjectId, + dataMessageId, + ) + + var additionalInfo: Map? = null + + if (!dataMessageStateEventDto.associatedInfo.isNullOrEmpty()) { + try { + additionalInfo = Json.decodeFromString( + MapSerializer(String.serializer(), String.serializer()), + dataMessageStateEventDto.associatedInfo!!, + ) + } catch (_: IOException) { + throw IllegalStateException( + "Cannot convert additionalInfo to Map. Please check its format.", + ) + } + } + + val messageState = requireNotNull(dataMessageStateEventDto.state) { + "Data Message state event's state can't be null." + } + val messageTime = requireNotNull(dataMessageStateEventDto.time) { + "Data Message state event's time can't be null." + } + + val stateEvent = org.radarbase.appserver.jersey.event.state.dto.DataMessageStateEventDto( + dataMessage, + messageState, + additionalInfo, + messageTime, + ) + dataMessageEventBus?.post(stateEvent) ?: logger.error("Event bus is not initialized.") + } + + @Throws(IllegalStateException::class) + private suspend fun checkState(dataMessageId: Long, state: MessageState?) { + if (EXTERNAL_EVENTS.contains(state)) { + if (dataMessageStateEventRepository.countByDataMessageId(dataMessageId) >= MAX_NUMBER_OF_STATES) { + throw IllegalStateException( + ("The max limit of state changes($MAX_NUMBER_OF_STATES) has been reached. Cannot add new states."), + ) + } + } else { + throw IllegalStateException(("The state $state is not an external state and cannot be updated by this endpoint.")) + } + } + + companion object { + private val logger = org.slf4j.LoggerFactory.getLogger(DataMessageStateEventService::class.java) + private val EXTERNAL_EVENTS = setOf( + MessageState.DELIVERED, + MessageState.DISMISSED, + MessageState.OPENED, + MessageState.UNKNOWN, + MessageState.ERRORED, + ) + private const val MAX_NUMBER_OF_STATES = 20 + + private fun nonNullDataMessage(stateEvent: DataMessageStateEvent): DataMessage = + checkNotNull(stateEvent.dataMessage) { + "DataMessage in state event data can't be null" + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmDataMessageService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmDataMessageService.kt index ddce617f0..0d972db52 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmDataMessageService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmDataMessageService.kt @@ -17,32 +17,41 @@ package org.radarbase.appserver.jersey.service import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import jakarta.inject.Named import org.radarbase.appserver.jersey.dto.fcm.FcmDataMessageDto import org.radarbase.appserver.jersey.dto.fcm.FcmDataMessages +import org.radarbase.appserver.jersey.enhancer.AppserverResourceEnhancer.Companion.DATA_MESSAGE_MAPPER import org.radarbase.appserver.jersey.entity.DataMessage +import org.radarbase.appserver.jersey.entity.Project import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.event.state.MessageState import org.radarbase.appserver.jersey.event.state.dto.DataMessageStateEventDto import org.radarbase.appserver.jersey.exception.AlreadyExistsException import org.radarbase.appserver.jersey.exception.InvalidNotificationDetailsException import org.radarbase.appserver.jersey.exception.InvalidUserDetailsException -import org.radarbase.appserver.jersey.mapper.DataMessageMapper +import org.radarbase.appserver.jersey.mapper.Mapper import org.radarbase.appserver.jersey.repository.DataMessageRepository import org.radarbase.appserver.jersey.repository.ProjectRepository import org.radarbase.appserver.jersey.repository.UserRepository -import org.radarbase.appserver.jersey.service.questionnaire_schedule.MessageSchedulerService +import org.radarbase.appserver.jersey.service.TaskService.Companion.nonNullUserId +import org.radarbase.appserver.jersey.service.questionnaire.schedule.MessageSchedulerService import org.radarbase.appserver.jersey.utils.checkInvalidDetails import org.radarbase.appserver.jersey.utils.checkPresence +import org.radarbase.appserver.jersey.utils.requireNotNullField import org.radarbase.jersey.exception.HttpNotFoundException import java.time.Instant import java.time.LocalDateTime +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract -class FcmDataMessageService( +@Suppress("unused") +class FcmDataMessageService @Inject constructor( private val dataMessageRepository: DataMessageRepository, private val userRepository: UserRepository, private val projectRepository: ProjectRepository, private val schedulerService: MessageSchedulerService, - private val dataMessageConverter: DataMessageMapper, + @field:Named(DATA_MESSAGE_MAPPER) private val dataMessageMapper: Mapper, private val dataMessageStateEventPublisher: EventBus, ) : DataMessageService { @@ -51,42 +60,34 @@ class FcmDataMessageService( suspend fun getAllDataMessages(): FcmDataMessages { val dataMessages = dataMessageRepository.findAll() - return FcmDataMessages() - .withDataMessages(dataMessageConverter.entitiesToDtos(dataMessages)) + return FcmDataMessages(dataMessageMapper.entitiesToDtos(dataMessages).toMutableList()) } suspend fun getDataMessageById(id: Long): FcmDataMessageDto { val dataMessage = dataMessageRepository.find(id) - return dataMessageConverter.entityToDto(dataMessage ?: DataMessage()) + return dataMessageMapper.entityToDto(dataMessage ?: DataMessage()) } suspend fun getDataMessagesBySubjectId(subjectId: String): FcmDataMessages { val user = this.userRepository.findBySubjectId(subjectId) - checkPresence(user, "user_not_found") { - INVALID_SUBJECT_ID_MESSAGE - } + checkPresenceOfUser(user) - val userId = user.id - checkNotNull(userId) { - "User id cannot be null" - } - val dataMessages = dataMessageRepository.findByUserId(userId) - return FcmDataMessages() - .withDataMessages(dataMessageConverter.entitiesToDtos(dataMessages)) + val dataMessages = dataMessageRepository.findByUserId(nonNullUserId(user)) + return FcmDataMessages( + dataMessageMapper.entitiesToDtos(dataMessages).toMutableList(), + ) } suspend fun getDataMessagesByProjectIdAndSubjectId( - projectId: String, subjectId: String, + projectId: String, + subjectId: String, ): FcmDataMessages { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val userId = user.id - checkNotNull(userId) { - "User id cannot be null" - } - val dataMessages = dataMessageRepository.findByUserId(userId) - return FcmDataMessages() - .withDataMessages(dataMessageConverter.entitiesToDtos(dataMessages)) + val dataMessages = dataMessageRepository.findByUserId(nonNullUserId(user)) + return FcmDataMessages( + dataMessageMapper.entitiesToDtos(dataMessages).toMutableList(), + ) } suspend fun getDataMessagesByProjectId(projectId: String): FcmDataMessages { @@ -96,76 +97,70 @@ class FcmDataMessageService( "Project not found with projectId $projectId" } - val projectId = project.id - checkNotNull(projectId) { - "User id cannot be null" - } - val users: List = this.userRepository.findByProjectId(projectId) + val users: List = this.userRepository.findByProjectId(nonNullProjectId(project)) val dataMessages: MutableSet = hashSetOf() users.flatMapTo(dataMessages) { user -> - val userId = user.id - checkNotNull(userId) { - "User id cannot be null" - } - this.dataMessageRepository.findByUserId(userId) + this.dataMessageRepository.findByUserId(nonNullUserId(user)) } - return FcmDataMessages() - .withDataMessages(dataMessageConverter.entitiesToDtos(dataMessages)) + return FcmDataMessages( + dataMessageMapper.entitiesToDtos(dataMessages).toMutableList(), + ) } suspend fun checkIfDataMessageExists(dataMessageDto: FcmDataMessageDto, subjectId: String): Boolean { val user = this.userRepository.findBySubjectId(subjectId) - checkPresence(user, "user_not_found") { INVALID_SUBJECT_ID_MESSAGE } + val dataMessage = DataMessage.DataMessageBuilder( + dataMessageMapper.dtoToEntity(dataMessageDto), + ).user(user).build() - val dataMessage = - DataMessage.DataMessageBuilder(dataMessageConverter.dtoToEntity(dataMessageDto)).user(user).build() - - - val userId = user.id - checkNotNull(userId) { - "User id cannot be null" - } - val dataMessages = this.dataMessageRepository.findByUserId(userId) + val dataMessages = this.dataMessageRepository.findByUserId(nonNullUserId(user)) return dataMessages.contains(dataMessage) } // TODO : WIP + @Suppress("UNUSED_PARAMETER") fun getFilteredDataMessages( type: String?, - delivered: Boolean, - ttlSeconds: Int, + delivered: Boolean?, + ttlSeconds: Int?, startTime: LocalDateTime?, endTime: LocalDateTime?, - limit: Int, + limit: Int?, ): FcmDataMessages? = null suspend fun addDataMessage( - dataMessageDto: FcmDataMessageDto, subjectId: String, projectId: String, + dataMessageDto: FcmDataMessageDto, + subjectId: String, + projectId: String, ): FcmDataMessageDto { val user = subjectAndProjectExistElseThrow(subjectId, projectId) if (!dataMessageRepository .existsByUserIdAndSourceIdAndScheduledTimeAndTtlSeconds( - user.id!!, - dataMessageDto.sourceId!!, - dataMessageDto.scheduledTime!!, - dataMessageDto.ttlSeconds, + nonNullUserId(user), + requireNotNullField(dataMessageDto.sourceId, "Data Message source id"), + requireNotNullField(dataMessageDto.scheduledTime, "Data Message scheduled time"), + requireNotNullField(dataMessageDto.ttlSeconds, "Data Message ttl seconds"), ) ) { - val dataMessageSaved = - this.dataMessageRepository.add( - DataMessage.DataMessageBuilder(dataMessageConverter.dtoToEntity(dataMessageDto)).user(user).build(), - ) - user.usermetrics!!.lastOpened = Instant.now() + val dataMessageSaved = dataMessageRepository.add( + DataMessage.DataMessageBuilder(dataMessageMapper.dtoToEntity(dataMessageDto)).user(user).build(), + ) + requireNotNullField(user.usermetrics, "User's user metrics").lastOpened = Instant.now() this.userRepository.update(user) addDataMessageStateEvent( - dataMessageSaved, MessageState.ADDED, dataMessageSaved.createdAt!!, + dataMessageSaved, + MessageState.ADDED, + requireNotNullField( + dataMessageSaved.createdAt, + "Data message creation timestamp", + ).toInstant(), ) this.schedulerService.schedule(dataMessageSaved) - return dataMessageConverter.entityToDto(dataMessageSaved) + return dataMessageMapper.entityToDto(dataMessageSaved) } else { throw AlreadyExistsException( "data_message_already_exists", @@ -175,29 +170,29 @@ class FcmDataMessageService( } private fun addDataMessageStateEvent( - dataMessage: DataMessage, state: MessageState, time: Instant, + dataMessage: DataMessage, + state: MessageState, + time: Instant, ) { - val dataMessageStateEvent = - DataMessageStateEventDto(dataMessage, state, null, time) + val dataMessageStateEvent = DataMessageStateEventDto( + dataMessage, + state, + null, + time, + ) dataMessageStateEventPublisher.post(dataMessageStateEvent) - } suspend fun updateDataMessage( - dataMessageDto: FcmDataMessageDto, subjectId: String, projectId: String, + dataMessageDto: FcmDataMessageDto, + subjectId: String, + projectId: String, ): FcmDataMessageDto { - checkInvalidDetails( - { - dataMessageDto.id == null - }, - { - "ID must be supplied for updating the data message" - }, + val dmDtoId = dataMessageDto.id ?: throw InvalidNotificationDetailsException( + "ID must be supplied for updating the data message", ) - val user = subjectAndProjectExistElseThrow(subjectId, projectId) - - val dataMessage = this.dataMessageRepository.find(dataMessageDto.id!!) + val dataMessage = this.dataMessageRepository.find(dmDtoId) checkPresence(dataMessage, "data_message_not_found") { "Data message does not exist. Please create first" @@ -211,36 +206,36 @@ class FcmDataMessageService( .fcmMessageId(dataMessageDto.hashCode().toString()) .build() - val dataMessageSaved = this.dataMessageRepository.update(newDataMessage)!! + val dataMessageSaved = this.dataMessageRepository.update(newDataMessage) ?: throw IllegalStateException( + "Data message cannot be updated", + ) addDataMessageStateEvent( - dataMessageSaved, MessageState.UPDATED, dataMessageSaved.updatedAt!!, + dataMessageSaved, + MessageState.UPDATED, + requireNotNullField( + dataMessageSaved.updatedAt, + "Data message update timestamp", + ).toInstant(), ) if (!dataMessage.delivered) { this.schedulerService.updateScheduled(dataMessageSaved) } - return dataMessageConverter.entityToDto(dataMessageSaved) + return dataMessageMapper.entityToDto(dataMessageSaved) } suspend fun removeDataMessagesForUser(projectId: String, subjectId: String) { - val user = subjectAndProjectExistElseThrow(subjectId, projectId) - + val userId = nonNullUserId(subjectAndProjectExistElseThrow(subjectId, projectId)) val dataMessages = this.dataMessageRepository.findByUserId( - checkNotNull(user.id) { - "User id cannot be null" - }, + userId, ) this.schedulerService.deleteScheduledMultiple(dataMessages) - this.dataMessageRepository.deleteByUserId( - checkNotNull(user.id) { - "User id cannot be null" - }, + userId, ) } suspend fun updateDeliveryStatus(fcmMessageId: String, isDelivered: Boolean) { val dataMessage = this.dataMessageRepository.findByFcmMessageId(fcmMessageId) - checkInvalidDetails( { dataMessage == null @@ -252,7 +247,6 @@ class FcmDataMessageService( val newDataMessage = DataMessage.DataMessageBuilder(dataMessage).delivered(isDelivered).build() this.dataMessageRepository.update(newDataMessage) - } // TODO: Investigate if data messages/notifications can be marked in the state CANCELLED when deleted. @@ -261,21 +255,9 @@ class FcmDataMessageService( subjectId: String, id: Long, ) { - val user = subjectAndProjectExistElseThrow(subjectId, projectId) - - if (this.dataMessageRepository.existsByIdAndUserId( - id, - checkNotNull(user.id) { - "User id cannot be null" - }, - ) - ) { - this.dataMessageRepository.deleteByIdAndUserId( - id, - checkNotNull(user.id) { - "User id cannot be null" - }, - ) + val userId = nonNullUserId(subjectAndProjectExistElseThrow(subjectId, projectId)) + if (dataMessageRepository.existsByIdAndUserId(id, userId)) { + this.dataMessageRepository.deleteByIdAndUserId(id, userId) } else { throw InvalidNotificationDetailsException( "Data message with the provided ID does not exist.", @@ -285,28 +267,30 @@ class FcmDataMessageService( suspend fun removeDataMessagesForUserUsingFcmToken(fcmToken: String) { val user = this.userRepository.findByFcmToken(fcmToken) - if (user == null) { throw InvalidUserDetailsException("The user with the given Fcm Token does not exist") } else { - this.dataMessageRepository.deleteByUserId(user.id!!) + this.dataMessageRepository.deleteByUserId(nonNullUserId(user)) /*User newUser = user1.setFcmToken(""); this.userRepository.save(newUser);*/ } } suspend fun addDataMessages( - dataMessageDtos: FcmDataMessages, subjectId: String, projectId: String, + dataMessageDtos: FcmDataMessages, + subjectId: String, + projectId: String, ): FcmDataMessages { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val dataMessages = dataMessageRepository.findByUserId(checkNotNull(user.id) { - "User id cannot be null" - }) - - val newDataMessages = - dataMessageDtos.dataMessages.map { dto -> dataMessageConverter.dtoToEntity(dto) } - .map { dm -> DataMessage.DataMessageBuilder(dm).user(user).build() } - .filter { dataMessage: DataMessage? -> !dataMessages.contains(dataMessage) } + val dataMessages = dataMessageRepository.findByUserId(nonNullUserId(user)) + + val newDataMessages = dataMessageDtos.dataMessages.map { dto -> + dataMessageMapper.dtoToEntity(dto) + }.map { dm -> + DataMessage.DataMessageBuilder(dm).user(user).build() + }.filter { dataMessage: DataMessage? -> + !dataMessages.contains(dataMessage) + } val savedDataMessages: List = newDataMessages.map { this.dataMessageRepository.add(it) @@ -316,13 +300,14 @@ class FcmDataMessageService( addDataMessageStateEvent( dm, MessageState.ADDED, - dm.createdAt!!, + requireNotNullField(dm.createdAt, "Data message creation timestamp").toInstant(), ) } this.schedulerService.scheduleMultiple(savedDataMessages) - return FcmDataMessages() - .withDataMessages(dataMessageConverter.entitiesToDtos(savedDataMessages)) + return FcmDataMessages( + dataMessageMapper.entitiesToDtos(dataMessages).toMutableList(), + ) } suspend fun subjectAndProjectExistElseThrow(subjectId: String, projectId: String): User { @@ -333,9 +318,7 @@ class FcmDataMessageService( "Project Id does not exist. Please create a project with the ID first", ) } - val user: User = this.userRepository.findBySubjectIdAndProjectId(subjectId, project.id!!)!! - checkPresence(user, "user_not_found") { INVALID_SUBJECT_ID_MESSAGE } @@ -343,12 +326,12 @@ class FcmDataMessageService( } suspend fun getDataMessageByProjectIdAndSubjectIdAndDataMessageId( - projectId: String, subjectId: String, dataMessageId: Long, + projectId: String, + subjectId: String, + dataMessageId: Long, ): DataMessage { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - - val dataMessage = dataMessageRepository.findByIdAndUserId(dataMessageId, user.id!!) - + val dataMessage = dataMessageRepository.findByIdAndUserId(dataMessageId, nonNullUserId(user)) checkInvalidDetails( { dataMessage == null @@ -362,7 +345,6 @@ class FcmDataMessageService( suspend fun getDataMessageByMessageId(messageId: String): DataMessage { val dataMessage = this.dataMessageRepository.findByFcmMessageId(messageId) - checkInvalidDetails( { dataMessage == null @@ -371,12 +353,25 @@ class FcmDataMessageService( "The Data message with FCM Message Id $messageId does not exist." }, ) - return dataMessage!! } companion object { private const val INVALID_SUBJECT_ID_MESSAGE = "The supplied Subject ID is invalid. No user found. Please Create a User First." + + @OptIn(ExperimentalContracts::class) + private fun checkPresenceOfUser(user: User?) { + contract { + returns() implies (user != null) + } + checkPresence(user, "user_not_found") { + INVALID_SUBJECT_ID_MESSAGE + } + } + + fun nonNullProjectId(project: Project): Long = checkNotNull(project.id) { + "User id cannot be null" + } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmNotificationService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmNotificationService.kt index 783d87707..6245bdd4c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmNotificationService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/FcmNotificationService.kt @@ -17,9 +17,13 @@ package org.radarbase.appserver.jersey.service import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import jakarta.inject.Named import org.radarbase.appserver.jersey.dto.fcm.FcmNotificationDto import org.radarbase.appserver.jersey.dto.fcm.FcmNotifications +import org.radarbase.appserver.jersey.enhancer.AppserverResourceEnhancer.Companion.NOTIFICATION_MAPPER import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.entity.Project import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.event.state.MessageState @@ -27,23 +31,27 @@ import org.radarbase.appserver.jersey.event.state.dto.NotificationStateEventDto import org.radarbase.appserver.jersey.exception.AlreadyExistsException import org.radarbase.appserver.jersey.exception.InvalidNotificationDetailsException import org.radarbase.appserver.jersey.exception.InvalidUserDetailsException -import org.radarbase.appserver.jersey.mapper.NotificationMapper +import org.radarbase.appserver.jersey.mapper.Mapper import org.radarbase.appserver.jersey.repository.NotificationRepository import org.radarbase.appserver.jersey.repository.ProjectRepository import org.radarbase.appserver.jersey.repository.UserRepository -import org.radarbase.appserver.jersey.service.questionnaire_schedule.MessageSchedulerService +import org.radarbase.appserver.jersey.service.TaskService.Companion.nonNullUserId +import org.radarbase.appserver.jersey.service.questionnaire.schedule.MessageSchedulerService import org.radarbase.appserver.jersey.utils.checkInvalidDetails import org.radarbase.appserver.jersey.utils.checkPresence -import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.appserver.jersey.utils.requireNotNullField import java.time.Instant import java.time.LocalDateTime +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract -class FcmNotificationService( +@Suppress("unused") +class FcmNotificationService @Inject constructor( private val notificationRepository: NotificationRepository, private val userRepository: UserRepository, private val projectRepository: ProjectRepository, private val schedulerService: MessageSchedulerService, - private val notificationConverter: NotificationMapper, + @param:Named(NOTIFICATION_MAPPER) private val notificationMapper: Mapper, private val notificationStateEventPublisher: EventBus, ) : NotificationService { @@ -52,30 +60,35 @@ class FcmNotificationService( suspend fun getAllNotifications(): FcmNotifications { val notifications: List = notificationRepository.findAll() - return FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(notifications)) + return FcmNotifications( + notificationMapper.entitiesToDtos(notifications).toMutableList(), + ) } suspend fun getNotificationById(id: Long): FcmNotificationDto { val notification: Notification? = notificationRepository.find(id) - return notificationConverter.entityToDto(notification ?: Notification()) + return notificationMapper.entityToDto(notification ?: Notification()) } suspend fun getNotificationsBySubjectId(subjectId: String): FcmNotifications { val user = this.userRepository.findBySubjectId(subjectId) - if (user == null) { - throw HttpNotFoundException("user_not_found", INVALID_SUBJECT_ID_MESSAGE) - } - val notifications: List = notificationRepository.findByUserId(user.id!!) - return FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(notifications)) + checkPresenceOfUser(user) + val notifications: List = notificationRepository.findByUserId(nonNullUserId(user)) + return FcmNotifications( + notificationMapper.entitiesToDtos(notifications).toMutableList(), + ) } suspend fun getNotificationsByProjectIdAndSubjectId( - projectId: String, subjectId: String, + projectId: String, + subjectId: String, ): FcmNotifications { return subjectAndProjectExistElseThrow(subjectId, projectId).let { user -> - notificationRepository.findByUserId(user.id!!) + notificationRepository.findByUserId(nonNullUserId(user)) }.let { notifications -> - FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(notifications)) + FcmNotifications( + notificationMapper.entitiesToDtos(notifications).toMutableList(), + ) } } @@ -83,17 +96,19 @@ class FcmNotificationService( return checkPresence(projectRepository.findByProjectId(projectId), "project_not_found") { "Project not found with projectId $projectId" }.let { project -> - this.userRepository.findByProjectId(project.id!!) + this.userRepository.findByProjectId(nonNullProjectId(project)) }.let { users -> hashSetOf().also { notifications -> users.map { user -> - notificationRepository.findByUserId(user.id!!) + notificationRepository.findByUserId(nonNullUserId(user)) }.forEach { userNotifications: List -> notifications.addAll(userNotifications) } } }.let { notifications -> - FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(notifications)) + FcmNotifications( + notificationMapper.entitiesToDtos(notifications).toMutableList(), + ) } } @@ -101,51 +116,40 @@ class FcmNotificationService( checkPresence(this.userRepository.findBySubjectId(subjectId), "user_not_found") { INVALID_SUBJECT_ID_MESSAGE }.let { user -> - val notification = - Notification.NotificationBuilder(notificationConverter.dtoToEntity(notificationDto)).user(user).build() - val notifications: List = this.notificationRepository.findByUserId(user.id!!) + val notification = Notification.NotificationBuilder( + notificationMapper.dtoToEntity(notificationDto), + ).user(user).build() + val notifications: List = this.notificationRepository.findByUserId(nonNullUserId(user)) return notifications.contains(notification) } } // TODO : WIP - suspend fun getFilteredNotifications( + @Suppress("UNUSED_PARAMETER") + fun getFilteredNotifications( type: String?, - delivered: Boolean, - ttlSeconds: Int, + delivered: Boolean?, + ttlSeconds: Int?, startTime: LocalDateTime?, endTime: LocalDateTime?, - limit: Int, + limit: Int?, ): FcmNotifications? = null suspend fun addNotification( - notificationDto: FcmNotificationDto, subjectId: String, projectId: String, schedule: Boolean, + notificationDto: FcmNotificationDto, + subjectId: String, + projectId: String, + schedule: Boolean, ): FcmNotificationDto { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val notification: Notification? = - notificationRepository.findByUserIdAndSourceIdAndScheduledTimeAndTitleAndBodyAndTypeAndTtlSeconds( - user.id!!, - notificationDto.sourceId!!, - notificationDto.scheduledTime!!, - notificationDto.title!!, - notificationDto.body!!, - notificationDto.type!!, - notificationDto.ttlSeconds, - ) + val notificationExists: Boolean = checkNotificationExists(notificationDto, subjectId, projectId) - if (notification == null) { - val notificationSaved = this.notificationRepository.add( - Notification.NotificationBuilder(notificationConverter.dtoToEntity(notificationDto)).user(user).build(), - ) - user.usermetrics!!.lastOpened = Instant.now() - this.userRepository.update(user) - addNotificationStateEvent( - notificationSaved, MessageState.ADDED, notificationSaved.createdAt!!, - ) + if (!notificationExists) { + val notificationSaved = addNotificationAndItsStateEvent(notificationDto, user) if (schedule) { this.schedulerService.schedule(notificationSaved) } - return notificationConverter.entityToDto(notificationSaved) + return notificationMapper.entityToDto(notificationSaved) } else { throw AlreadyExistsException( "notifications.already_exists", @@ -155,31 +159,17 @@ class FcmNotificationService( } suspend fun addNotification( - notificationDto: FcmNotificationDto, subjectId: String, projectId: String, + notificationDto: FcmNotificationDto, + subjectId: String, + projectId: String, ): FcmNotificationDto { + val notificationExists = checkNotificationExists(notificationDto, subjectId, projectId) val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val notification: Notification? = - notificationRepository.findByUserIdAndSourceIdAndScheduledTimeAndTitleAndBodyAndTypeAndTtlSeconds( - user.id!!, - notificationDto.sourceId!!, - notificationDto.scheduledTime!!, - notificationDto.title!!, - notificationDto.body!!, - notificationDto.type!!, - notificationDto.ttlSeconds, - ) - if (notification == null) { - val notificationSaved = this.notificationRepository.add( - Notification.NotificationBuilder(notificationConverter.dtoToEntity(notificationDto)).user(user).build(), - ) - user.usermetrics!!.lastOpened = Instant.now() - this.userRepository.update(user) - addNotificationStateEvent( - notificationSaved, MessageState.ADDED, notificationSaved.createdAt!!, - ) - this.schedulerService.schedule(notificationSaved) - return notificationConverter.entityToDto(notificationSaved) + if (!notificationExists) { + val savedNotification = addNotificationAndItsStateEvent(notificationDto, user) + this.schedulerService.schedule(savedNotification) + return notificationMapper.entityToDto(savedNotification) } else { throw AlreadyExistsException( "notifications.already_exists", @@ -188,71 +178,115 @@ class FcmNotificationService( } } + suspend fun addNotificationAndItsStateEvent( + notificationDto: FcmNotificationDto, + user: User, + ): Notification { + val savedNotification = this.notificationRepository.add( + Notification.NotificationBuilder(notificationMapper.dtoToEntity(notificationDto)).user(user).build(), + ) + requireNotNullField(user.usermetrics, "User's user metrics").lastOpened = Instant.now() + this.userRepository.update(user) + addNotificationStateEvent( + savedNotification, + MessageState.ADDED, + requireNotNullField( + savedNotification.createdAt, + "Notification creation timestamp", + ).toInstant(), + ) + + return savedNotification + } + + suspend fun checkNotificationExists( + notificationDto: FcmNotificationDto, + subjectId: String, + projectId: String, + ): Boolean { + return subjectAndProjectExistElseThrow(subjectId, projectId).let { user -> + notificationRepository.existsByUserIdAndSourceIdAndScheduledTimeAndTitleAndBodyAndTypeAndTtlSeconds( + nonNullUserId(user), + requireNotNullField(notificationDto.sourceId, "Notification Source Id"), + requireNotNullField(notificationDto.scheduledTime, "Notification Scheduled time"), + requireNotNullField(notificationDto.title, "Notification Title"), + requireNotNullField(notificationDto.body, "Notification Body"), + requireNotNullField(notificationDto.type, "Notification Type"), + requireNotNullField(notificationDto.ttlSeconds, "Notification TTL seconds"), + ) + } + } + private fun addNotificationStateEvent( - notification: Notification, state: MessageState, time: Instant, + notification: Notification, + state: MessageState, + time: Instant, ) { val notificationStateEvent = NotificationStateEventDto(notification, state, null, time) notificationStateEventPublisher.post(notificationStateEvent) } suspend fun updateNotification( - notificationDto: FcmNotificationDto, subjectId: String, projectId: String, + notificationDto: FcmNotificationDto, + subjectId: String, + projectId: String, ): FcmNotificationDto { val notificationId = notificationDto.id - - checkInvalidDetails( - { notificationId == null }, - { - "ID must be supplied for updating the notification" - }, - ) + ?: throw InvalidNotificationDetailsException("ID must be supplied for updating the notification") val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val notification = checkPresence(this.notificationRepository.find(notificationId!!), "notification_not_found") { + val notification = checkPresence(this.notificationRepository.find(notificationId), "notification_not_found") { "Notification does not exist. Please create one first" } - val newNotification = - Notification.NotificationBuilder(notification).body(notificationDto.body) - .scheduledTime(notificationDto.scheduledTime) - .sourceId(notificationDto.sourceId).title(notificationDto.title).ttlSeconds(notificationDto.ttlSeconds) - .type(notificationDto.type).user(user).fcmMessageId(notificationDto.hashCode().toString()).build() - val notificationSaved = this.notificationRepository.update(newNotification) + val newNotification = Notification.NotificationBuilder(notification).body(notificationDto.body) + .scheduledTime(notificationDto.scheduledTime).sourceId(notificationDto.sourceId) + .title(notificationDto.title).ttlSeconds(notificationDto.ttlSeconds).type(notificationDto.type).user(user) + .fcmMessageId(notificationDto.hashCode().toString()).build() + val notificationSaved = this.notificationRepository.update(newNotification) ?: throw IllegalStateException( + "Returned notification is null. Notification didn't updated successfully in the database.", + ) addNotificationStateEvent( - notificationSaved!!, MessageState.UPDATED, notificationSaved.updatedAt!!, + notificationSaved, + MessageState.UPDATED, + requireNotNullField( + notificationSaved.updatedAt, + "Notification update timestamp", + ).toInstant(), ) if (!notification.delivered) { this.schedulerService.updateScheduled(notificationSaved) } - return notificationConverter.entityToDto(notificationSaved) + return notificationMapper.entityToDto(notificationSaved) } suspend fun scheduleAllUserNotifications(subjectId: String, projectId: String): FcmNotifications { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val notifications: List = notificationRepository.findByUserId(user.id!!) + val notifications: List = notificationRepository.findByUserId(nonNullUserId(user)) this.schedulerService.scheduleMultiple(notifications) - return FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(notifications)) + return FcmNotifications( + notificationMapper.entitiesToDtos(notifications).toMutableList(), + ) } suspend fun scheduleNotification(subjectId: String, projectId: String, notificationId: Long): FcmNotificationDto { val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val notification = notificationRepository.findByIdAndUserId(notificationId, user.id!!) + val notification = notificationRepository.findByIdAndUserId(notificationId, nonNullUserId(user)) checkPresence(notification, "notification_not_found") { "The Notification with Id $notificationId does not exist in project $projectId for user $subjectId" } this.schedulerService.schedule(notification) - return notificationConverter.entityToDto(notification) + return notificationMapper.entityToDto(notification) } suspend fun removeNotificationsForUser(projectId: String, subjectId: String) { - val user = subjectAndProjectExistElseThrow(subjectId, projectId) - - val notifications: List = this.notificationRepository.findByUserId(user.id!!) + val userId = nonNullUserId(subjectAndProjectExistElseThrow(subjectId, projectId)) + val notifications: List = this.notificationRepository.findByUserId(userId) this.schedulerService.deleteScheduledMultiple(notifications) - this.notificationRepository.deleteByUserId(user.id!!) + this.notificationRepository.deleteByUserId(userId) } suspend fun updateDeliveryStatus(fcmMessageId: String, isDelivered: Boolean) { @@ -264,48 +298,47 @@ class FcmNotificationService( "Notification with the provided FCM message ID does not exist." }, ) - val newNotif = Notification.NotificationBuilder(notification).delivered(isDelivered).build() - this.notificationRepository.update(newNotif) + val newNotification = Notification.NotificationBuilder(notification).delivered(isDelivered).build() + this.notificationRepository.update(newNotification) } // TODO: Investigate if notifications can be marked in the state CANCELLED when deleted. - suspend fun deleteNotificationByProjectIdAndSubjectIdAndNotificationId(projectId: String, subjectId: String, id: Long) { - val user = subjectAndProjectExistElseThrow(subjectId, projectId) - val userId = user.id + suspend fun deleteNotificationByProjectIdAndSubjectIdAndNotificationId( + projectId: String, + subjectId: String, + id: Long, + ) { + val userId = nonNullUserId(subjectAndProjectExistElseThrow(subjectId, projectId)) - if (this.notificationRepository.existsByIdAndUserId(id, userId!!)) { + if (this.notificationRepository.existsByIdAndUserId(id, userId)) { this.schedulerService.deleteScheduled( this.notificationRepository.findByIdAndUserId(id, userId)!!, ) this.notificationRepository.deleteByIdAndUserId(id, userId) - } else throw InvalidNotificationDetailsException( - "Notification with the provided ID does not exist.", - ) + } else { + throw InvalidNotificationDetailsException( + "Notification with the provided ID does not exist.", + ) + } } suspend fun removeNotificationsForUserUsingTaskId(projectId: String, subjectId: String, taskId: Long) { - val user = subjectAndProjectExistElseThrow(subjectId, projectId) + val userId = nonNullUserId(subjectAndProjectExistElseThrow(subjectId, projectId)) - val notifications: List = this.notificationRepository.findByUserIdAndTaskId(user.id!!, taskId) + val notifications: List = this.notificationRepository.findByUserIdAndTaskId(userId, taskId) this.schedulerService.deleteScheduledMultiple(notifications) - this.notificationRepository.deleteByUserIdAndTaskId(user.id!!, taskId) + this.notificationRepository.deleteByUserIdAndTaskId(userId, taskId) } suspend fun removeNotificationsForUserUsingFcmToken(fcmToken: String) { val user = this.userRepository.findByFcmToken(fcmToken) - - checkInvalidDetails( - { user == null }, - { "The user with the given Fcm Token does not exist" }, - ) - - + ?: throw InvalidUserDetailsException("The user with the given Fcm Token does not exist") + val userId = nonNullUserId(user) this.schedulerService.deleteScheduledMultiple( - this.notificationRepository.findByUserId(user!!.id!!), + this.notificationRepository.findByUserId(userId), ) - - this.notificationRepository.deleteByUserId(user.id!!) + this.notificationRepository.deleteByUserId(userId) } suspend fun deleteNotificationsByTaskId(task: Task) { @@ -318,50 +351,40 @@ class FcmNotificationService( } suspend fun addNotifications( - notificationDtos: FcmNotifications, subjectId: String, projectId: String, schedule: Boolean, + notificationDtos: FcmNotifications, + subjectId: String, + projectId: String, + schedule: Boolean, ): FcmNotifications { - - val newNotifications: List = subjectAndProjectExistElseThrow(subjectId, projectId).let { user -> - notificationRepository.findByUserId(user.id!!).let { notifications -> - notificationDtos.notifications.map { dto: FcmNotificationDto -> - notificationConverter.dtoToEntity(dto) - }.map { notification -> - Notification.NotificationBuilder(notification).user(user).build() - }.filter { notification -> - !notifications.contains(notification) - } - } - } - - val savedNotifications = newNotifications.map { - this.notificationRepository.add(it) - } + val savedNotifications = addNewNotifications(notificationDtos, subjectId, projectId) savedNotifications.forEach { n: Notification -> addNotificationStateEvent( n, MessageState.ADDED, - n.createdAt!!, + requireNotNullField(n.createdAt, "Notification creation timestamp").toInstant(), ) } if (schedule) { this.schedulerService.scheduleMultiple(savedNotifications) } - return FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(savedNotifications)) + return FcmNotifications( + notificationMapper.entitiesToDtos(savedNotifications).toMutableList(), + ) } suspend fun addNotifications(notifications: List?, user: User): List { notifications ?: return listOf() val newNotifications: List = notifications.filter { notification: Notification -> - notificationRepository.findByUserIdAndSourceIdAndScheduledTimeAndTitleAndBodyAndTypeAndTtlSeconds( - user.id!!, - notification.sourceId!!, - notification.scheduledTime!!, - notification.title!!, - notification.body!!, - notification.type!!, - notification.ttlSeconds, - ) == null + !notificationRepository.existsByUserIdAndSourceIdAndScheduledTimeAndTitleAndBodyAndTypeAndTtlSeconds( + requireNotNullField(user.id, "User id"), + requireNotNullField(notification.sourceId, "Notification Source Id"), + requireNotNullField(notification.scheduledTime, "Notification Scheduled time"), + requireNotNullField(notification.title, "Notification Title"), + requireNotNullField(notification.body, "Notification Body"), + requireNotNullField(notification.type, "Notification Type"), + requireNotNullField(notification.ttlSeconds, "Notification TTL seconds"), + ) } val savedNotifications: List = newNotifications.map { @@ -371,7 +394,7 @@ class FcmNotificationService( addNotificationStateEvent( n!!, MessageState.ADDED, - n.createdAt!!, + requireNotNullField(n.createdAt, "Notification creation timestamp").toInstant(), ) } this.schedulerService.scheduleMultiple(savedNotifications) @@ -379,13 +402,34 @@ class FcmNotificationService( } suspend fun addNotifications( - notificationDtos: FcmNotifications, subjectId: String, projectId: String, + notificationDtos: FcmNotifications, + subjectId: String, + projectId: String, ): FcmNotifications { + val savedNotifications = addNewNotifications(notificationDtos, subjectId, projectId) + savedNotifications.forEach { n: Notification -> + addNotificationStateEvent( + n, + MessageState.ADDED, + requireNotNullField(n.createdAt, "Notification creation timestamp").toInstant(), + ) + } + this.schedulerService.scheduleMultiple(savedNotifications) + return FcmNotifications( + notificationMapper.entitiesToDtos(savedNotifications).toMutableList(), + ) + } + + suspend fun addNewNotifications( + notificationDtos: FcmNotifications, + subjectId: String, + projectId: String, + ): List { val newNotifications: List = subjectAndProjectExistElseThrow(subjectId, projectId).let { user -> - notificationRepository.findByUserId(user.id!!).let { notifications -> + notificationRepository.findByUserId(nonNullUserId(user)).let { notifications -> notificationDtos.notifications.map { dto: FcmNotificationDto -> - notificationConverter.dtoToEntity(dto) + notificationMapper.dtoToEntity(dto) }.map { notification -> Notification.NotificationBuilder(notification).user(user).build() }.filter { notification -> @@ -394,19 +438,9 @@ class FcmNotificationService( } } - val savedNotifications = newNotifications.map { + return newNotifications.map { this.notificationRepository.add(it) } - savedNotifications.forEach { n: Notification -> - addNotificationStateEvent( - n, - MessageState.ADDED, - n.createdAt!!, - ) - } - - this.schedulerService.scheduleMultiple(savedNotifications) - return FcmNotifications().withNotifications(notificationConverter.entitiesToDtos(savedNotifications)) } suspend fun subjectAndProjectExistElseThrow(subjectId: String, projectId: String): User { @@ -420,22 +454,17 @@ class FcmNotificationService( } suspend fun getNotificationByProjectIdAndSubjectIdAndNotificationId( - projectId: String, subjectId: String, notificationId: Long, + projectId: String, + subjectId: String, + notificationId: Long, ): Notification { val user = subjectAndProjectExistElseThrow(subjectId, projectId) + val notification = notificationRepository.findByIdAndUserId(notificationId, nonNullUserId(user)) + ?: throw InvalidNotificationDetailsException( + "The Notification with Id $notificationId does not exist in project $projectId for user $subjectId", + ) - val notification = notificationRepository.findByIdAndUserId(notificationId, user.id!!) - - checkInvalidDetails( - { - notification == null - }, - { - "The Notification with Id $notificationId does not exist in project $projectId for user $subjectId" - }, - ) - - return notification!! + return notification } suspend fun getNotificationByMessageId(messageId: String): Notification { @@ -455,4 +484,18 @@ class FcmNotificationService( private const val INVALID_SUBJECT_ID_MESSAGE = "The supplied Subject ID is invalid. No user found. Please Create a User First." } + + @OptIn(ExperimentalContracts::class) + private fun checkPresenceOfUser(user: User?) { + contract { + returns() implies (user != null) + } + checkPresence(user, "user_not_found") { + INVALID_SUBJECT_ID_MESSAGE + } + } + + fun nonNullProjectId(project: Project): Long = checkNotNull(project.id) { + "User id cannot be null" + } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/NotificationStateEventService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/NotificationStateEventService.kt new file mode 100644 index 000000000..f7333c928 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/NotificationStateEventService.kt @@ -0,0 +1,166 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.service + +import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.glassfish.hk2.api.ServiceLocator +import org.radarbase.appserver.jersey.dto.NotificationStateEventDto +import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.entity.NotificationStateEvent +import org.radarbase.appserver.jersey.event.state.MessageState +import org.radarbase.appserver.jersey.repository.NotificationStateEventRepository +import java.io.IOException + +@Suppress("unused") +class NotificationStateEventService @Inject constructor( + private val notificationStateEventRepository: NotificationStateEventRepository, + private val notificationService: FcmNotificationService, + private val serviceLocator: ServiceLocator, +) { + private var notificationStateEventBus: EventBus? = null + get() { + if (field == null) { + return serviceLocator.getService(EventBus::class.java) + ?.also { field = it } + } + return field + } + + suspend fun addNotificationStateEvent(notificationStateEvent: NotificationStateEvent) { + if (notificationStateEvent.state == MessageState.CANCELLED) { + // the notification will be removed shortly + return + } + notificationStateEventRepository.add(notificationStateEvent) + } + + suspend fun getNotificationStateEvents( + projectId: String, + subjectId: String, + notificationId: Long, + ): List { + notificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId( + projectId, + subjectId, + notificationId, + ) + val stateEvents: List = + notificationStateEventRepository.findByNotificationId(notificationId) + return stateEvents.map { notificationStateEvent: NotificationStateEvent -> + NotificationStateEventDto( + notificationStateEvent.id, + nonNullNotification(notificationStateEvent).id, + notificationStateEvent.state, + notificationStateEvent.time, + notificationStateEvent.associatedInfo, + ) + } + } + + suspend fun getNotificationStateEventsByNotificationId( + notificationId: Long, + ): List { + val stateEvents = notificationStateEventRepository.findByNotificationId(notificationId) + return stateEvents.map { notificationStateEvent: NotificationStateEvent -> + NotificationStateEventDto( + notificationStateEvent.id, + nonNullNotification(notificationStateEvent).id, + notificationStateEvent.state, + notificationStateEvent.time, + notificationStateEvent.associatedInfo, + ) + } + } + + suspend fun publishNotificationStateEventExternal( + projectId: String, + subjectId: String, + notificationId: Long, + notificationStateEventDto: NotificationStateEventDto, + ) { + checkState(notificationId, notificationStateEventDto.state) + val notification = notificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId( + projectId, + subjectId, + notificationId, + ) + + var additionalInfo: Map? = null + if (!notificationStateEventDto.associatedInfo.isNullOrEmpty()) { + try { + additionalInfo = Json.decodeFromString( + MapSerializer(String.serializer(), String.serializer()), + notificationStateEventDto.associatedInfo!!, + ) + } catch (_: IOException) { + throw IllegalStateException( + "Cannot convert additionalInfo to Map. Please check its format.", + ) + } + } + + val messageState = requireNotNull(notificationStateEventDto.state) { + "Notification state event's state can't be null." + } + val messageTime = requireNotNull(notificationStateEventDto.time) { + "Notification state event's time can't be null." + } + + val stateEvent = org.radarbase.appserver.jersey.event.state.dto.NotificationStateEventDto( + notification, + messageState, + additionalInfo, + messageTime, + ) + notificationStateEventBus?.post(stateEvent) ?: log.error("Event bus is not initialized") + } + + @Throws(IllegalStateException::class) + private suspend fun checkState(notificationId: Long, state: MessageState?) { + if (EXTERNAL_EVENTS.contains(state)) { + if (notificationStateEventRepository.countByNotificationId(notificationId) + >= MAX_NUMBER_OF_STATES + ) { + throw IllegalStateException("The max limit of state changes($MAX_NUMBER_OF_STATES) has been reached. Cannot add new states.") + } + } else { + throw IllegalStateException("The state $state is not an external state and cannot be updated by this endpoint.") + } + } + + companion object { + private val log = org.slf4j.LoggerFactory.getLogger(NotificationStateEventService::class.java) + private const val MAX_NUMBER_OF_STATES = 20 + + private val EXTERNAL_EVENTS = setOf( + MessageState.DELIVERED, + MessageState.DISMISSED, + MessageState.OPENED, + MessageState.UNKNOWN, + MessageState.ERRORED, + ) + + private fun nonNullNotification(stateEvent: NotificationStateEvent): Notification = + checkNotNull(stateEvent.notification) { + "DataMessage in state event data can't be null" + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/ProjectService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/ProjectService.kt index 167b70d88..05c36d4e9 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/ProjectService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/ProjectService.kt @@ -28,7 +28,6 @@ import org.radarbase.appserver.jersey.mapper.ProjectMapper import org.radarbase.appserver.jersey.repository.ProjectRepository import org.radarbase.appserver.jersey.utils.checkInvalidProjectDetails import org.radarbase.appserver.jersey.utils.checkPresence -import org.radarbase.jersey.exception.HttpNotFoundException import org.slf4j.LoggerFactory /** @@ -43,7 +42,7 @@ import org.slf4j.LoggerFactory @Suppress("unused") class ProjectService @Inject constructor( private val projectRepository: ProjectRepository, - @Named(PROJECT_MAPPER) private val projectMapper: Mapper, + @param:Named(PROJECT_MAPPER) private val projectMapper: Mapper, ) { /** * Retrieves all projects from the repository. @@ -100,8 +99,6 @@ class ProjectService @Inject constructor( suspend fun addProject(projectDTO: ProjectDto): ProjectDto { val projectId: String? = projectDTO.projectId - logger.info("Adding project with id $projectId adding to project") - checkInvalidProjectDetails( projectDTO, { projectDTO.id != null }, @@ -139,30 +136,11 @@ class ProjectService @Inject constructor( * @throws org.radarbase.jersey.exception.HttpNotFoundException if the project to update does not exist */ suspend fun updateProject(projectDto: ProjectDto): ProjectDto { - checkInvalidProjectDetails( - projectDto, - { projectDto.id == null }, - { "The 'id' of the project must be supplied for updating project" }, - ) - - val project = projectRepository.find(projectDto.id!!) ?: throw HttpNotFoundException( - "project_not_found", - "Project with id ${projectDto.id} does not exists. Please create project first", - ) - - project.apply { - projectId = projectDto.projectId - } - - val savedProject = projectRepository.update(project) - ?: throw HttpNotFoundException( - "project_not_found", - "Project with id ${projectDto.id} does not exists. Please create project first", - ) - - println("Project updated successfully: $savedProject") - return projectMapper.entityToDto(savedProject).also { - println("Sending: $it") + return projectRepository.updateEfficiently(projectDto).let { savedProject -> + println("Project updated successfully: $savedProject") + projectMapper.entityToDto(savedProject).also { + println("Sending: $it") + } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskService.kt index 993234233..e5b9ffc80 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskService.kt @@ -17,6 +17,7 @@ package org.radarbase.appserver.jersey.service import com.google.common.eventbus.EventBus +import jakarta.inject.Inject import org.radarbase.appserver.jersey.dto.protocol.AssessmentType import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User @@ -30,12 +31,13 @@ import org.radarbase.appserver.jersey.utils.checkPresence import java.sql.Timestamp import java.time.Instant -class TaskService( +@Suppress("unused") +class TaskService @Inject constructor( private val taskRepository: TaskRepository, private val userRepository: UserRepository, private val eventPublisher: EventBus, ) { - suspend fun getAllProjects(): List { + suspend fun getAllTasks(): List { return taskRepository.findAll() } @@ -52,11 +54,8 @@ class TaskService( checkPresence(user, "user_not_found") { INVALID_SUBJECT_ID_MESSAGE } - val userId = user.id - checkNotNull(userId) { - "User id is null when fetching tasks" - } - return taskRepository.findByUserId(userId) + + return taskRepository.findByUserId(user.let(::nonNullUserId)) } suspend fun getTasksBySubjectIdAndType(subjectId: String, type: AssessmentType): List { @@ -64,11 +63,12 @@ class TaskService( checkPresence(user, "user_not_found") { INVALID_SUBJECT_ID_MESSAGE } - return taskRepository.findByUserIdAndType(user.id!!, type) + + return taskRepository.findByUserIdAndType(nonNullUserId(user), type) } suspend fun getTasksByUser(user: User): List { - return taskRepository.findByUserId(user.id!!) + return taskRepository.findByUserId(nonNullUserId(user)) } suspend fun getTasksBySpecification(spec: QuerySpecification): List { @@ -85,51 +85,65 @@ class TaskService( } suspend fun addTask(task: Task): Task { - val user = task.user - - val alreadyExists = - this.taskRepository.existsByUserIdAndNameAndTimestamp(user!!.id!!, task.name!!, task.timestamp!!) + val (user, taskName, taskTimestamp) = validateUserTaskNameAndTaskTimestamp(task) + val alreadyExists = this.taskRepository.existsByUserIdAndNameAndTimestamp( + nonNullUserId(user), + taskName, + taskTimestamp, + ) if (!alreadyExists) { val saved = this.taskRepository.add(task) - user.usermetrics!!.lastOpened = Instant.now() + val userMetrics = checkNotNull(user.usermetrics) { "User metrics cannot be null" } + userMetrics.lastOpened = Instant.now() this.userRepository.update(user) - addTaskStateEvent(saved, TaskState.ADDED, saved.createdAt) + val taskCreationTimestamp = checkNotNull(saved.createdAt) { "Task creation timestamp cannot be null" } + addTaskStateEvent(saved, TaskState.ADDED, taskCreationTimestamp.toInstant()) return saved - } else throw AlreadyExistsException( - "task_already_exists", "The Task Already exists. Please Use update endpoint", - ) + } else { + throw AlreadyExistsException( + "task_already_exists", + "The Task Already exists. Please Use update endpoint", + ) + } } - suspend fun addTasks(tasks: List?, user: User): List { - val newTasks = tasks?.filter { task -> + suspend fun addTasks(tasks: List, user: User): List { + val newTasks = tasks.filter { task -> + val taskName = checkNotNull(task.name) { "Task name cannot be null" } + val taskTimestamp = checkNotNull(task.timestamp) { "Task timestamp cannot be null" } + !this.taskRepository.existsByUserIdAndNameAndTimestamp( - user.id!!, - task.name!!, - task.timestamp!!, + nonNullUserId(user), + taskName, + taskTimestamp, ) - } ?: return emptyList() + } val saved = newTasks.map { task -> taskRepository.add(task) } saved.forEach { t -> - addTaskStateEvent(t, TaskState.ADDED, t.createdAt) + val taskCreationTimestamp = checkNotNull(t.createdAt) { "Task creation timestamp cannot be null" } + addTaskStateEvent(t, TaskState.ADDED, taskCreationTimestamp.toInstant()) } return saved } - private fun addTaskStateEvent(t: Task?, @Suppress("SameParameterValue") state: TaskState?, time: Instant?) { + private fun addTaskStateEvent(t: Task?, @Suppress("SameParameterValue") state: TaskState, time: Instant) { val taskStateEventDto = TaskStateEventDto(t, state, null, time) eventPublisher.post(taskStateEventDto) } - suspend fun updateTaskStatus(oldTask: Task, state: TaskState): Task { - val user = oldTask.user + suspend fun updateTaskStatus(oldTask: Task, state: TaskState): Task? { + val (user, taskName, taskTimestamp) = validateUserTaskNameAndTaskTimestamp(oldTask) - val doesntExists = - !this.taskRepository.existsByUserIdAndNameAndTimestamp(user!!.id!!, oldTask.name!!, oldTask.timestamp!!) + val doesntExists = !this.taskRepository.existsByUserIdAndNameAndTimestamp( + nonNullUserId(user), + taskName, + taskTimestamp, + ) checkPresence(doesntExists, "task_not_found") { "The Task ${oldTask.id} does not exist to set to state $state Please Use add endpoint" @@ -140,11 +154,26 @@ class TaskService( oldTask.timeCompleted = Timestamp.from(Instant.now()) } oldTask.status = state - return this.taskRepository.update(oldTask)!! + return this.taskRepository.update(oldTask) } companion object { private const val INVALID_SUBJECT_ID_MESSAGE = "The supplied Subject ID is invalid. No user found. Please Create a User First." + + fun nonNullUserId(user: User): Long = checkNotNull(user.id) { + "User id cannot be null" + } + + fun validateUserTaskNameAndTaskTimestamp(task: Task): Triple { + val user = task.user + checkPresence(user, "user_not_found") { + INVALID_SUBJECT_ID_MESSAGE + } + val taskName = checkNotNull(task.name) { "Task name cannot be null" } + val taskTimestamp = checkNotNull(task.timestamp) { "Task timestamp cannot be null" } + + return Triple(user, taskName, taskTimestamp) + } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskStateEventService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskStateEventService.kt new file mode 100644 index 000000000..3b81ba71e --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/TaskStateEventService.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.service + +import com.google.common.eventbus.EventBus +import jakarta.inject.Inject +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.glassfish.hk2.api.ServiceLocator +import org.radarbase.appserver.jersey.dto.TaskStateEventDto +import org.radarbase.appserver.jersey.entity.Task +import org.radarbase.appserver.jersey.entity.TaskStateEvent +import org.radarbase.appserver.jersey.event.state.TaskState +import org.radarbase.appserver.jersey.repository.TaskStateEventRepository +import org.slf4j.LoggerFactory +import java.io.IOException +import javax.naming.SizeLimitExceededException + +@Suppress("unused") +class TaskStateEventService @Inject constructor( + private val taskStateEventRepository: TaskStateEventRepository, + private val taskService: TaskService, + private val notificationService: FcmNotificationService, + private val serviceLocator: ServiceLocator, +) { + private var taskStateEventBus: EventBus? = null + get() { + if (field == null) { + return serviceLocator.getService(EventBus::class.java) + ?.also { field = it } + } + return field + } + + suspend fun addTaskStateEvent(taskStateEvent: TaskStateEvent) { + taskStateEventRepository.add(taskStateEvent) + val task = checkNotNull(taskStateEvent.task) { "Task in task state event can't be null" } + val state = checkNotNull(taskStateEvent.state) { "State in task state event can't be null" } + + taskService.updateTaskStatus(task, state) + if (taskStateEvent.state == TaskState.COMPLETED) { + notificationService.deleteNotificationsByTaskId(task) + } + } + + @Suppress("UNUSED_PARAMETER") + suspend fun getTaskStateEvents( + projectId: String?, + subjectId: String?, + taskId: Long, + ): List { + val task: Task = taskService.getTaskById(taskId) + val stateEvents: List = taskStateEventRepository.findByTaskId(taskId) + return stateEvents.map { ts -> + TaskStateEventDto( + id = ts.id, + taskId = task.id, + state = ts.state, + time = ts.time, + associatedInfo = ts.associatedInfo, + ) + } + } + + suspend fun getTaskStateEventsByTaskId( + taskId: Long, + ): List { + val stateEvents: List = taskStateEventRepository.findByTaskId(taskId) + return stateEvents.map { ts -> + TaskStateEventDto( + id = ts.id, + taskId = ts.task?.id, + state = ts.state, + time = ts.time, + associatedInfo = ts.associatedInfo, + ) + } + } + + @Suppress("UNUSED_PARAMETER") + @Throws(SizeLimitExceededException::class) + suspend fun publishNotificationStateEventExternal( + projectId: String, + subjectId: String, + taskId: Long, + taskStateEventDto: TaskStateEventDto, + ) { + val taskState = requireNotNull(taskStateEventDto.state) { "State is missing" } + checkState(taskId, taskState) + val task = taskService.getTaskById(taskId) + + val additionalInfo: Map? = if (!taskStateEventDto.associatedInfo.isNullOrEmpty()) { + try { + Json.decodeFromString( + MapSerializer(String.serializer(), String.serializer()), + taskStateEventDto.associatedInfo!!, + ) + } catch (exc: IOException) { + throw IllegalStateException( + "Cannot convert additionalInfo to Map. Please check its format.", + exc, + ) + } + } else { + null + } + + val stateEvent = org.radarbase.appserver.jersey.event.state.dto.TaskStateEventDto( + task, + taskStateEventDto.state, + additionalInfo, + taskStateEventDto.time, + ) + taskStateEventBus?.post(stateEvent) ?: logger.warn("EventBus is not initialized.") + } + + @Throws(SizeLimitExceededException::class, IllegalStateException::class) + private suspend fun checkState(taskId: Long, state: TaskState) { + if (state in EXTERNAL_EVENTS) { + if (taskStateEventRepository.countByTaskId(taskId) >= MAX_NUMBER_OF_STATES) { + throw SizeLimitExceededException( + "The max limit of state changes($MAX_NUMBER_OF_STATES) has been reached. Cannot add new states.", + ) + } + } else { + throw IllegalStateException( + "The state $state is not an external state and cannot be updated by this endpoint.", + ) + } + } + + companion object { + private val logger = LoggerFactory.getLogger(TaskStateEventService::class.java) + + private val EXTERNAL_EVENTS: Set = setOf( + TaskState.COMPLETED, + TaskState.UNKNOWN, + TaskState.ERRORED, + ) + + private const val MAX_NUMBER_OF_STATES = 20 + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/UserService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/UserService.kt index c3f23311b..41ea4fe55 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/UserService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/UserService.kt @@ -29,6 +29,7 @@ import org.radarbase.appserver.jersey.mapper.Mapper import org.radarbase.appserver.jersey.mapper.UserMapper import org.radarbase.appserver.jersey.repository.ProjectRepository import org.radarbase.appserver.jersey.repository.UserRepository +import org.radarbase.appserver.jersey.service.questionnaire.schedule.QuestionnaireScheduleService import org.radarbase.appserver.jersey.utils.checkInvalidDetails import org.radarbase.appserver.jersey.utils.checkPresence import org.radarbase.jersey.exception.HttpNotFoundException @@ -41,7 +42,7 @@ class UserService @Inject constructor( @Named(USER_MAPPER) val userMapper: Mapper, val userRepository: UserRepository, val projectRepository: ProjectRepository, -// val scheduleService: QuestionnaireScheduleService, + val scheduleService: QuestionnaireScheduleService, config: AppserverConfig, ) { private val sendEmailNotifications: Boolean = config.email.enabled ?: false @@ -199,7 +200,7 @@ class UserService @Inject constructor( ) val email: String? = userDto.email - if (sendEmailNotifications && (email == null || email.isEmpty())) { + if (sendEmailNotifications && email.isNullOrEmpty()) { logger.warn( "No email address was provided for new subject '{}'. The option to send notifications via email " + "('email.enabled') will not work for this subject. Consider to provide a valid email " + @@ -218,7 +219,7 @@ class UserService @Inject constructor( userRepository.add(this) } -// this.scheduleService.generateScheduleForUser(savedUser) + this.scheduleService.generateScheduleForUser(savedUser) return userMapper.entityToDto(savedUser) } @@ -235,7 +236,6 @@ class UserService @Inject constructor( * @throws InvalidUserDetailsException If the user with the specified subject ID does not exist within the project. */ suspend fun updateUser(userDto: FcmUserDto): FcmUserDto { -// TODO update to use Id instead of subjectId val project: Project = checkPresence( projectRepository.findByProjectId( checkNotNull(userDto.projectId) { "Project ID must not be null" }, @@ -246,19 +246,18 @@ class UserService @Inject constructor( } val user: User? = userRepository.findBySubjectIdAndProjectId( - requireNotNull(userDto.subjectId) { "Subject id must not be null" }, - requireNotNull(project.id) { "Project id must not be null" }, + requireNotNull(userDto.subjectId) { "Subject id must be non-null" }, + requireNotNull(project.id) { "Project `id` must be non-null" }, ) checkInvalidDetails( - { user == null }, - { - "The user with specified subject ID ${userDto.subjectId} does not exist in project ID " - "${userDto.projectId} Please use post endpoint to create the user." - }, - ) + user == null, + ) { + "The user with specified subject ID ${userDto.subjectId} does not exist in project ID " + "${userDto.projectId} Please use post endpoint to create the user." + } - user!!.apply { + user.apply { this.fcmToken = userDto.fcmToken this.usermetrics = UserMapper.getValidUserMetrics(userDto) this.enrolmentDate = userDto.enrolmentDate @@ -271,25 +270,21 @@ class UserService @Inject constructor( } } - val savedUser: User? = userRepository.update(user) + val savedUser: User = userRepository.update(user) ?: throw HttpNotFoundException( + "user_not_found", + "User with id ${user.id} not found.", + ) // Generate schedule for user if (user.attributes != userDto.attributes || user.timezone != userDto.timezone || user.enrolmentDate != userDto.enrolmentDate || user.language != userDto.language) { -// this.scheduleService.generateScheduleForUser(savedUser) + this.scheduleService.generateScheduleForUser(savedUser) } - checkNotNull(savedUser) { - "Null user is returned when updating user with subjectId ${userDto.subjectId} and projectId ${userDto.projectId}" - } return userMapper.entityToDto(savedUser) } - suspend fun updateLastDelivered(fcmToken: String?, lastDelivered: Instant?) { + suspend fun updateLastDelivered(fcmToken: String, lastDelivered: Instant?) { val user: User = checkPresence( - userRepository.findByFcmToken( - requireNotNull(fcmToken) { - "FCM token cannot be null when updating last delivered time for user" - }, - ), + userRepository.findByFcmToken(fcmToken), "user_not_found", ) { "User with the fcm-token $fcmToken doesn't exists" @@ -321,13 +316,12 @@ class UserService @Inject constructor( ) checkInvalidDetails( - { user == null }, - { - "The user with specified subject ID $subjectId does not exist in project ID $projectId. Please specify a valid user for deleting." - }, - ) + user == null, + ) { + "The user with specified subject ID $subjectId does not exist in project ID $projectId. Please specify a valid user for deleting." + } - this.userRepository.delete(user!!) + this.userRepository.delete(user) } companion object { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubClient.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubClient.kt index 543f7965c..34b6996d4 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubClient.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubClient.kt @@ -60,8 +60,9 @@ class GithubClient @Inject constructor( private val authorizationHeader: String get() { - return if (githubToken.isNullOrEmpty()) "" - else { + return if (githubToken.isNullOrEmpty()) { + "" + } else { "Bearer " + githubToken.trim { it <= ' ' } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubService.kt index 47e65d637..b3afa32d7 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/GithubService.kt @@ -37,14 +37,14 @@ import java.time.Duration */ class GithubService @Inject constructor( private val githubClient: GithubClient, - config: AppserverConfig + config: AppserverConfig, ) { private val cacheTime: Long private val retryTime: Long private val maxSize: Int init { - config.github.cache.let { githubCacheConfig -> + config.github.cache.let { githubCacheConfig -> cacheTime = checkNotNull(githubCacheConfig.cacheDuration) { "Github cache duration cannot be null in config" } retryTime = checkNotNull(githubCacheConfig.retryDuration) { "Github retry duration cannot be null in config" } maxSize = githubCacheConfig.maxCacheSize diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/DefaultProtocolGenerator.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/DefaultProtocolGenerator.kt index 207aa54e3..6c6a0098d 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/DefaultProtocolGenerator.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/DefaultProtocolGenerator.kt @@ -83,7 +83,7 @@ class DefaultProtocolGenerator @Inject constructor( @Throws(IOException::class) override suspend fun getProtocol(projectId: String): Protocol { try { - return cachedProjectProtocolMap[projectId]!! + return cachedProjectProtocolMap.getByKey(projectId)!! } catch (ex: IOException) { logger.warn( "Cannot retrieve Protocols for project {} : {}, Using cached values.", @@ -114,7 +114,7 @@ class DefaultProtocolGenerator @Inject constructor( return cachedProtocolMap.get(true)[subjectId] } catch (ex: IOException) { logger.warn("Cannot retrieve Protocols, using cached values if available.", ex) - return cachedProtocolMap.getCachedMap()[subjectId]!! + return cachedProtocolMap.getCachedMap()[subjectId] } catch (ex: Exception) { logger.warn( "Exception while fetching protocols for subject {}", @@ -135,10 +135,7 @@ class DefaultProtocolGenerator @Inject constructor( */ override suspend fun getProtocolForSubject(subjectId: String): Protocol? { try { - val protocol = cachedProtocolMap[subjectId] - if (protocol == null) { - return forceGetProtocolForSubject(subjectId) - } + val protocol = cachedProtocolMap.getByKey(subjectId) ?: return forceGetProtocolForSubject(subjectId) return protocol } catch (ex: IOException) { logger.warn( @@ -156,7 +153,7 @@ class DefaultProtocolGenerator @Inject constructor( subjectId, ex, ) - throw IOException("Exception while fetching protocols for subject $subjectId", ex) + throw IOException("Exception while fetching protocols for subject $subjectId. ${ex.message}", ex) } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/GithubProtocolFetcherStrategy.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/GithubProtocolFetcherStrategy.kt index 06b019360..3a3631145 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/GithubProtocolFetcherStrategy.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/github/protocol/impl/GithubProtocolFetcherStrategy.kt @@ -16,14 +16,16 @@ package org.radarbase.appserver.jersey.service.github.protocol.impl -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode import io.ktor.utils.io.errors.IOException import jakarta.inject.Inject import jakarta.ws.rs.WebApplicationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.radarbase.appserver.jersey.config.AppserverConfig import org.radarbase.appserver.jersey.dto.protocol.GithubContent import org.radarbase.appserver.jersey.dto.protocol.Protocol @@ -35,6 +37,7 @@ import org.radarbase.appserver.jersey.service.github.GithubService import org.radarbase.appserver.jersey.service.github.protocol.ProtocolFetcherStrategy import org.radarbase.appserver.jersey.utils.cache.CachedMap import org.radarbase.appserver.jersey.utils.mapParallel +import org.radarbase.appserver.jersey.utils.requireNotNullField import org.radarbase.appserver.jersey.utils.withReentrantLock import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -50,14 +53,12 @@ import java.time.Duration * @property protocolRepo The configured GitHub repository path where protocols are stored. * @property protocolFileName The name of the protocol file used to identify relevant files in the repository. * @property protocolBranch The branch of the repository from which protocols should be retrieved. - * @property objectMapper A JSON utility for serialization and deserialization of data. * @property userRepository Repository for accessing User data from the database. * @property projectRepository Repository for accessing Project data from the database. * @property githubService A service for interacting with the GitHub API. */ class GithubProtocolFetcherStrategy @Inject constructor( config: AppserverConfig, - private val objectMapper: ObjectMapper, private val userRepository: UserRepository, private val projectRepository: ProjectRepository, private val githubService: GithubService, @@ -82,8 +83,14 @@ class GithubProtocolFetcherStrategy @Inject constructor( } private val fetchLock = Mutex() - private val localMapper: ObjectMapper = objectMapper.copy().apply { - configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + @OptIn(ExperimentalSerializationApi::class) + private val localJson = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + encodeDefaults = true + prettyPrint = false } /** @@ -107,7 +114,8 @@ class GithubProtocolFetcherStrategy @Inject constructor( val protocolPaths: Set = getProtocolPaths() users.mapParallel(Dispatchers.Default) { - fetchProtocolForSingleUser(it, it.project!!.projectId!!, protocolPaths) + val project = requireNotNullField(it.project, "User's project") + fetchProtocolForSingleUser(it, requireNotNullField(project.projectId, "Project Id"), protocolPaths) }.filter { it.protocol != null }.associate { it.id to it.protocol!! }.also { logger.debug("Fetched Protocols from Github") } @@ -127,23 +135,24 @@ class GithubProtocolFetcherStrategy @Inject constructor( projectId: String, protocolPaths: Set, ): ProtocolCacheEntry { - val attributes: Map? = user.attributes ?: emptyMap() + val attributes: Map = user.attributes ?: emptyMap() + val subjectId: String = requireNotNullField(user.subjectId, "User subject ID") val attributeMap: Map = protocolPaths.filter { it.contains(projectId) }.map { convertPathToAttributeMap(it, projectId).filter { entry -> - attributes?.get(entry.key) == entry.value + attributes[entry.key] == entry.value } }.maxByOrNull { it.size } ?: emptyMap() return try { val attributePath = convertAttributeMapToPath(attributeMap, projectId) projectProtocolUriMap.get()[attributePath]?.let { - ProtocolCacheEntry(user.subjectId!!, getProtocolFromUrl(it)) - } ?: ProtocolCacheEntry(user.subjectId!!, null) + ProtocolCacheEntry(subjectId, getProtocolFromUrl(it)) + } ?: ProtocolCacheEntry(subjectId, null) } catch (_: Exception) { - ProtocolCacheEntry(user.subjectId!!, null) + ProtocolCacheEntry(subjectId, null) } } @@ -156,15 +165,16 @@ class GithubProtocolFetcherStrategy @Inject constructor( */ override suspend fun fetchProtocolsPerProject(): Map { val protocolPaths = getProtocolPaths() - + if (protocolPaths.isEmpty()) return emptyMap() return projectRepository.findAll().mapParallel(Dispatchers.Default) { project -> - val projectId = project.projectId!! + val projectId = requireNotNullField(project.projectId, "Project Id") val protocol = protocolPaths.lastOrNull { it.contains(projectId) }?.let { path -> try { val uri = projectProtocolUriMap.get()[path] ?: return@let null getProtocolFromUrl(uri) - } catch (_: Exception) { - null + } catch (e: Exception) { +// null + throw e } } ProtocolCacheEntry(projectId, protocol) @@ -236,27 +246,38 @@ class GithubProtocolFetcherStrategy @Inject constructor( val protocolUriMap = mutableMapOf() try { - val treeContent = githubService.getGithubContentWithoutCache( + val branchJson = githubService.getGithubContentWithoutCache( "$GITHUB_API_URI$protocolRepo/branches/$protocolBranch", - ).run { - this@GithubProtocolFetcherStrategy.getArrayNode(this) - }.run { - this.findValue("tree").findValue("sha").asText() - }.let { treeSha -> - githubService.getGithubContent("$GITHUB_API_URI$protocolRepo/git/trees/$treeSha?recursive=true") - } + ) + + val branchElement = Json.parseToJsonElement(branchJson).jsonObject + val treeSha = branchElement["commit"] + ?.jsonObject?.get("commit") + ?.jsonObject?.get("tree") + ?.jsonObject?.get("sha") + ?.jsonPrimitive?.content + ?: throw IOException("Missing tree sha in branch JSON") + + val treeJson = githubService.getGithubContent( + "$GITHUB_API_URI$protocolRepo/git/trees/$treeSha?recursive=true", + ) + val treeElement = Json.parseToJsonElement(treeJson).jsonObject + + val treeArray = treeElement["tree"]?.jsonArray + ?: throw IOException("Missing tree array in tree JSON") - val tree = getArrayNode(treeContent).get("tree") - for (jsonNode in tree) { - val path = jsonNode.get("path").asText() + for (node in treeArray) { + val obj = node.jsonObject + val path = obj["path"]?.jsonPrimitive?.content ?: continue if (path.contains(this.protocolFileName)) { - protocolUriMap[path] = URI.create(jsonNode.get("url").asText()) + val url = obj["url"]?.jsonPrimitive?.content ?: continue + protocolUriMap[path] = URI.create(url) } } } catch (e: WebApplicationException) { - throw io.ktor.utils.io.errors.IOException("Failed to retrieve protocols URIs from github", e) + throw IOException("Failed to retrieve protocols URIs from github", e) } catch (e: Exception) { - throw io.ktor.utils.io.errors.IOException("Exception when retrieving protocol uri map info", e) + throw IOException("Exception when retrieving protocol uri map info", e) } protocolUriMap } @@ -272,20 +293,9 @@ class GithubProtocolFetcherStrategy @Inject constructor( @Throws(IOException::class) private suspend fun getProtocolFromUrl(uri: URI): Protocol { val contentString = githubService.getGithubContent(uri.toString()) - val content = localMapper.readValue(contentString, GithubContent::class.java) - return localMapper.readValue(content.content, Protocol::class.java) - } - - /** - * Parses the given JSON string into an ObjectNode. - * - * @param json The JSON string to be parsed. - * @return The parsed ObjectNode representation of the JSON string. - */ - private fun getArrayNode(json: String): ObjectNode { - objectMapper.factory.createParser(json).use { parserProtocol -> - return objectMapper.readTree(parserProtocol) - } + val protocol = localJson.decodeFromString(contentString).content + ?: throw IOException("Protocol content is null") + return localJson.decodeFromString(protocol) } companion object { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/ProtocolHandlerType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/ProtocolHandlerType.kt index d19e3e511..7c6fca7ca 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/ProtocolHandlerType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/ProtocolHandlerType.kt @@ -19,5 +19,5 @@ package org.radarbase.appserver.jersey.service.protocol.handler.factory enum class ProtocolHandlerType { SIMPLE, CLINICAL, - OTHER + OTHER, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerFactory.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerFactory.kt index d8e225e2e..dec6c3086 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerFactory.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerFactory.kt @@ -19,6 +19,7 @@ package org.radarbase.appserver.jersey.service.protocol.handler.factory import org.radarbase.appserver.jersey.service.protocol.handler.ProtocolHandler import org.radarbase.appserver.jersey.service.protocol.handler.impl.SimpleRepeatProtocolHandler +@Suppress("UNUSED_PARAMETER") object RepeatProtocolHandlerFactory { fun getRepeatProtocolHandler(protocolHandlerType: RepeatProtocolHandlerType): ProtocolHandler { return SimpleRepeatProtocolHandler() diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerType.kt index f863935de..66699b2be 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatProtocolHandlerType.kt @@ -19,5 +19,5 @@ package org.radarbase.appserver.jersey.service.protocol.handler.factory enum class RepeatProtocolHandlerType { SIMPLE, DAYOFWEEK, - OTHER + OTHER, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatQuestionnaireHandlerType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatQuestionnaireHandlerType.kt index 1ce17fcac..b376ce875 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatQuestionnaireHandlerType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/factory/RepeatQuestionnaireHandlerType.kt @@ -20,5 +20,5 @@ enum class RepeatQuestionnaireHandlerType { SIMPLE, DAYOFWEEKMAP, RANDOM, - OTHER + OTHER, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/ClinicalProtocolHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/ClinicalProtocolHandler.kt index 7c54b4655..30fb46254 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/ClinicalProtocolHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/ClinicalProtocolHandler.kt @@ -36,7 +36,7 @@ class ClinicalProtocolHandler : ProtocolHandler { override suspend fun handle( assessmentSchedule: AssessmentSchedule, assessment: Assessment, - user: User + user: User, ): AssessmentSchedule { assessmentSchedule.name = assessment.name return assessmentSchedule diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/CompletedQuestionnaireHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/CompletedQuestionnaireHandler.kt index 262fedd8f..a8eb5634d 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/CompletedQuestionnaireHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/CompletedQuestionnaireHandler.kt @@ -30,13 +30,13 @@ import java.util.TimeZone open class CompletedQuestionnaireHandler( private val prevTasks: List, - private val prevTimezone: String + private val prevTimezone: String, ) : ProtocolHandler { override suspend fun handle( assessmentSchedule: AssessmentSchedule, assessment: Assessment, - user: User + user: User, ): AssessmentSchedule { val currentTimezone = checkPresence(user.timezone, "invalid_user_details") { "User's timezone can't be null in completed questionnaire handler" @@ -52,7 +52,7 @@ open class CompletedQuestionnaireHandler( currentTasks: List, previousTasks: List, currentTimezone: String, - prevTimezone: String + prevTimezone: String, ): List { currentTasks.mapParallel(Dispatchers.Default) { newTask -> val matching = if (currentTimezone != prevTimezone) { @@ -90,7 +90,7 @@ open class CompletedQuestionnaireHandler( private fun getPreviousTimezoneEquivalent( taskTimestamp: Timestamp, newTimezone: String, - prevTimezone: String + prevTimezone: String, ): Timestamp { val timezoneDiff = TimeZone.getTimeZone(newTimezone).rawOffset - TimeZone.getTimeZone(prevTimezone).rawOffset return Timestamp(taskTimestamp.time + timezoneDiff) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/DisabledNotificationHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/DisabledNotificationHandler.kt index 7d4edb70d..8fe19bfbf 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/DisabledNotificationHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/DisabledNotificationHandler.kt @@ -25,7 +25,7 @@ class DisabledNotificationHandler : ProtocolHandler { override suspend fun handle( assessmentSchedule: AssessmentSchedule, assessment: Assessment, - user: User + user: User, ): AssessmentSchedule { return assessmentSchedule.also { it.notifications = emptyList() diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/RandomRepeatQuestionnaireHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/RandomRepeatQuestionnaireHandler.kt index 664b57538..58ca8f900 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/RandomRepeatQuestionnaireHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/RandomRepeatQuestionnaireHandler.kt @@ -22,13 +22,12 @@ import org.radarbase.appserver.jersey.dto.questionnaire.AssessmentSchedule import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.service.protocol.handler.ProtocolHandler -import org.radarbase.appserver.jersey.service.questionnaire_schedule.task.TaskGeneratorService import org.radarbase.appserver.jersey.service.protocol.time.TimeCalculatorService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.task.TaskGeneratorService import java.time.Instant import java.util.TimeZone - -class RandomRepeatQuestionnaireHandler: ProtocolHandler { +class RandomRepeatQuestionnaireHandler : ProtocolHandler { private val defaultTaskCompletionWindow = 86_400_000L private val timeCalculatorService = TimeCalculatorService() private val taskGeneratorService = TaskGeneratorService() @@ -36,9 +35,8 @@ class RandomRepeatQuestionnaireHandler: ProtocolHandler { override suspend fun handle( assessmentSchedule: AssessmentSchedule, assessment: Assessment, - user: User + user: User, ): AssessmentSchedule { - val referenceTimestamps = assessmentSchedule.referenceTimestamps ?: return assessmentSchedule.apply { tasks = emptyList() } @@ -46,7 +44,7 @@ class RandomRepeatQuestionnaireHandler: ProtocolHandler { val tasks = generateTasks( assessment, referenceTimestamps, - user + user, ) assessmentSchedule.tasks = tasks return assessmentSchedule @@ -55,7 +53,7 @@ class RandomRepeatQuestionnaireHandler: ProtocolHandler { private fun generateTasks( assessment: Assessment, referenceTimestamps: List, - user: User + user: User, ): List { val timezone = TimeZone.getTimeZone(user.timezone) val repeatQuestionnaire = assessment.protocol?.repeatQuestionnaire @@ -79,7 +77,7 @@ class RandomRepeatQuestionnaireHandler: ProtocolHandler { private fun getRandomAmountInRange(range: Array): Int { val (lowerLimit, upperLimit) = range - return (lowerLimit .. upperLimit).random() + return (lowerLimit..upperLimit).random() } private fun calculateCompletionWindow(completionWindow: TimePeriod?): Long { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleNotificationHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleNotificationHandler.kt index 6c5c2bdc3..62789d388 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleNotificationHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleNotificationHandler.kt @@ -23,8 +23,8 @@ import org.radarbase.appserver.jersey.entity.Notification import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.service.protocol.handler.ProtocolHandler -import org.radarbase.appserver.jersey.service.questionnaire_schedule.notification.NotificationType -import org.radarbase.appserver.jersey.service.questionnaire_schedule.notification.TaskNotificationGeneratorService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.notification.NotificationType +import org.radarbase.appserver.jersey.service.questionnaire.schedule.notification.TaskNotificationGeneratorService import org.radarbase.appserver.jersey.utils.mapParallel import java.time.Instant import java.time.temporal.ChronoUnit @@ -73,8 +73,11 @@ class SimpleNotificationHandler : ProtocolHandler { } suspend fun generateNotifications( - tasks: List, user: User, - title: String, body: String, emailEnabled: Boolean, + tasks: List, + user: User, + title: String, + body: String, + emailEnabled: Boolean, ): List { return tasks.mapParallel(Dispatchers.Default) { task: Task -> task.timestamp?.let { taskTimeStamp -> diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleReminderHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleReminderHandler.kt index 6131ab87d..dd49d5cd2 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleReminderHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleReminderHandler.kt @@ -27,8 +27,8 @@ import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.service.protocol.handler.ProtocolHandler import org.radarbase.appserver.jersey.service.protocol.time.TimeCalculatorService -import org.radarbase.appserver.jersey.service.questionnaire_schedule.notification.NotificationType -import org.radarbase.appserver.jersey.service.questionnaire_schedule.notification.TaskNotificationGeneratorService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.notification.NotificationType +import org.radarbase.appserver.jersey.service.questionnaire.schedule.notification.TaskNotificationGeneratorService import org.radarbase.appserver.jersey.utils.flatMapParallel import java.time.Instant import java.time.temporal.ChronoUnit @@ -80,11 +80,16 @@ class SimpleReminderHandler : ProtocolHandler { } suspend fun generateReminders( - tasks: List, assessment: Assessment, timezone: TimeZone, - user: User, title: String, body: String, emailEnabled: Boolean, + tasks: List, + assessment: Assessment, + timezone: TimeZone, + user: User, + title: String, + body: String, + emailEnabled: Boolean, ): List = coroutineScope { tasks.flatMapParallel { task: Task -> - val reminders = assessment.protocol?.reminders ?: return@flatMapParallel emptyList() + val reminders = assessment.protocol?.reminders ?: return@flatMapParallel emptyList() val repeatReminders = reminders.repeat ?: return@flatMapParallel emptyList() (1..repeatReminders).map { repeat: Int -> @@ -93,17 +98,23 @@ class SimpleReminderHandler : ProtocolHandler { val timestamp = timeCalculatorService.advanceRepeat(task.timestamp!!.toInstant(), offset, timezone) taskNotificationGeneratorService.createNotification( - task, timestamp, title, body, emailEnabled, + task, + timestamp, + title, + body, + emailEnabled, ).also { it.user = user } } }.awaitAll() }.filter { notification -> - (Instant.now().isBefore( - notification.scheduledTime!! - .plus(notification.ttlSeconds.toLong(), ChronoUnit.SECONDS), - )) + ( + Instant.now().isBefore( + notification.scheduledTime!! + .plus(notification.ttlSeconds.toLong(), ChronoUnit.SECONDS), + ) + ) } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatProtocolHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatProtocolHandler.kt index 7d95132ef..16c65ae47 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatProtocolHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatProtocolHandler.kt @@ -33,7 +33,7 @@ class SimpleRepeatProtocolHandler : ProtocolHandler { override suspend fun handle( assessmentSchedule: AssessmentSchedule, assessment: Assessment, - user: User + user: User, ): AssessmentSchedule { val timezone = user.timezone requireNotNull(timezone) { @@ -52,7 +52,7 @@ class SimpleRepeatProtocolHandler : ProtocolHandler { private fun generateReferenceTimestamps( assessment: Assessment, startTime: Instant, - timezoneId: String + timezoneId: String, ): List { val timezone = TimeZone.getTimeZone(timezoneId) val repeatProtocol: RepeatProtocol? = assessment.protocol?.repeatProtocol @@ -60,7 +60,7 @@ class SimpleRepeatProtocolHandler : ProtocolHandler { val repeatProtocolUnit: String? = repeatProtocol?.unit val repeatProtocolAmount: Int? = repeatProtocol?.amount - if (repeatProtocol == null || repeatProtocolUnit == null || repeatProtocolAmount == null) { + if (repeatProtocol == null || repeatProtocolUnit == null || repeatProtocolAmount == null) { logger.warn("Repeat protocol is null for assessment in SimpleRepeatProtocolHandler") return emptyList() } @@ -84,7 +84,7 @@ class SimpleRepeatProtocolHandler : ProtocolHandler { private fun calculateValidStartTime( startTime: Instant, timezone: TimeZone, - simpleRepeatProtocol: TimePeriod + simpleRepeatProtocol: TimePeriod, ): Instant { var referenceTime = startTime val defaultStartTime = timeCalculatorService.advanceRepeat(Instant.now(), MINUS_ONE_WEEK, timezone) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatQuestionnaireHandler.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatQuestionnaireHandler.kt index 0803816bc..3cc76719d 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatQuestionnaireHandler.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/handler/impl/SimpleRepeatQuestionnaireHandler.kt @@ -26,8 +26,8 @@ import org.radarbase.appserver.jersey.dto.questionnaire.AssessmentSchedule import org.radarbase.appserver.jersey.entity.Task import org.radarbase.appserver.jersey.entity.User import org.radarbase.appserver.jersey.service.protocol.handler.ProtocolHandler -import org.radarbase.appserver.jersey.service.questionnaire_schedule.task.TaskGeneratorService import org.radarbase.appserver.jersey.service.protocol.time.TimeCalculatorService +import org.radarbase.appserver.jersey.service.questionnaire.schedule.task.TaskGeneratorService import org.radarbase.appserver.jersey.utils.flatMapParallel import java.time.Instant import java.util.TimeZone @@ -37,7 +37,9 @@ class SimpleRepeatQuestionnaireHandler : ProtocolHandler { private val taskGeneratorService = TaskGeneratorService() override suspend fun handle( - assessmentSchedule: AssessmentSchedule, assessment: Assessment, user: User, + assessmentSchedule: AssessmentSchedule, + assessment: Assessment, + user: User, ): AssessmentSchedule { val referenceTimestamp = assessmentSchedule.referenceTimestamps ?: return assessmentSchedule.also { it.tasks = emptyList() } @@ -51,7 +53,9 @@ class SimpleRepeatQuestionnaireHandler : ProtocolHandler { } private suspend fun generateTasks( - assessment: Assessment, referenceTimestamps: List, user: User, + assessment: Assessment, + referenceTimestamps: List, + user: User, ): List = coroutineScope { val timezone = TimeZone.getTimeZone(user.timezone) @@ -60,7 +64,7 @@ class SimpleRepeatQuestionnaireHandler : ProtocolHandler { val unitsFromZero: List = repeatQuestionnaire.unitsFromZero.orEmpty() val completionWindow = this@SimpleRepeatQuestionnaireHandler.calculateCompletionWindow( - assessment.protocol?.completionWindow + assessment.protocol?.completionWindow, ) referenceTimestamps.flatMapParallel { referenceTimestamp: Instant -> diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/time/TimeCalculatorService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/time/TimeCalculatorService.kt index 6ce11e788..1d1499a3c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/time/TimeCalculatorService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/protocol/time/TimeCalculatorService.kt @@ -96,11 +96,13 @@ class TimeCalculatorService { * and days, where a week is assumed to always contain 7 days. */ private const val WEEK_TO_DAYS = 7 + /** * Constant representing the number of days in a month. It is used as an approximate value * where 31 days is considered the standard number of days in a month for calculations. */ private const val MONTH_TO_DAYS = 31 + /** * Represents the number of days in a standard non-leap year. * diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageJob.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageJob.kt index 47149727b..2f641865e 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageJob.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageJob.kt @@ -26,21 +26,22 @@ import org.radarbase.appserver.jersey.service.FcmDataMessageService import org.radarbase.appserver.jersey.service.FcmNotificationService import org.radarbase.appserver.jersey.service.transmitter.DataMessageTransmitter import org.radarbase.appserver.jersey.service.transmitter.NotificationTransmitter +import org.radarbase.jersey.service.AsyncCoroutineService import org.slf4j.LoggerFactory /** * A [Job] that sends notification/message to the device or email when executed. */ class MessageJob @Inject constructor( - private val notificationTransmitters: List, - private val dataMessageTransmitters: List, + private val notificationTransmitter: NotificationTransmitter, + private val dataMessageTransmitter: DataMessageTransmitter, private val notificationService: FcmNotificationService, - private val dataMessageService: FcmDataMessageService + private val dataMessageService: FcmDataMessageService, + private val asyncService: AsyncCoroutineService, ) : Job { /** * Called by the `[org.quartz.Scheduler]` when a `[org.quartz.Trigger] - `* fires that is associated with the `Job`. - * + `* fires that is associated with the `Job`. * * The implementation may wish to set a [result][JobExecutionContext.setResult] * object on the [JobExecutionContext] before this method exits. The result itself is @@ -61,33 +62,44 @@ class MessageJob @Inject constructor( try { when (type) { MessageType.NOTIFICATION -> { - val notification = notificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId( - projectId, subjectId, messageId - ) - notificationTransmitters.forEach { transmitter -> - try { - transmitter.send(notification) - } catch (e: MessageTransmitException) { - exceptions.add(e) + asyncService.runBlocking { + val notification = + notificationService.getNotificationByProjectIdAndSubjectIdAndNotificationId( + projectId, + subjectId, + messageId, + ) + + notificationTransmitter.let { transmitter -> + try { + transmitter.send(notification) + } catch (e: MessageTransmitException) { + exceptions.add(e) + } } } } MessageType.DATA -> { - val dataMessage = dataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId( - projectId, subjectId, messageId - ) - dataMessageTransmitters.forEach { transmitter -> - try { - transmitter.send(dataMessage) - } catch (e: MessageTransmitException) { - exceptions.add(e) + asyncService.runBlocking { + val dataMessage = dataMessageService.getDataMessageByProjectIdAndSubjectIdAndDataMessageId( + projectId, + subjectId, + messageId, + ) + + dataMessageTransmitter.let { transmitter -> + try { + transmitter.send(dataMessage) + } catch (e: MessageTransmitException) { + exceptions.add(e) + } } } } MessageType.UNKNOWN -> { - logger.debug("Not executing job with type MessageType.UNKNOWN") + logger.warn("Not executing job with type MessageType.UNKNOWN") } } } catch (e: Exception) { @@ -99,8 +111,8 @@ class MessageJob @Inject constructor( * Exceptions that occurred while transmitting the message via the * transmitters. At present, only the FcmTransmitter affects the job execution state. */ - val fcmException: FcmMessageTransmitException? = exceptions.filterIsInstance() - .firstOrNull() + val fcmException: FcmMessageTransmitException? = + exceptions.filterIsInstance().firstOrNull() if (fcmException != null) { throw JobExecutionException("Could not transmit a message", fcmException) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageType.kt index 615149f00..774bc1a27 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/MessageType.kt @@ -19,5 +19,5 @@ package org.radarbase.appserver.jersey.service.quartz enum class MessageType { NOTIFICATION, DATA, - UNKNOWN + UNKNOWN, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerService.kt index 7f1044638..04e595bba 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerService.kt @@ -29,8 +29,13 @@ interface SchedulerService { fun checkJobExists(jobKey: JobKey): Boolean + fun checkTriggerExists(triggerKey: TriggerKey): Boolean + fun updateScheduledJob( - jobKey: JobKey, triggerKey: TriggerKey, jobDataMap: JobDataMap, associatedObject: Any? + jobKey: JobKey, + triggerKey: TriggerKey, + jobDataMap: JobDataMap, + associatedObject: Any?, ) fun deleteScheduledJobs(jobKeys: List) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerServiceImpl.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerServiceImpl.kt index 73ecae425..02b436c78 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerServiceImpl.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/quartz/SchedulerServiceImpl.kt @@ -17,6 +17,8 @@ package org.radarbase.appserver.jersey.service.quartz import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.quartz.JobDataMap import org.quartz.JobDetail import org.quartz.JobKey @@ -33,11 +35,15 @@ import java.util.Date class SchedulerServiceImpl @Inject constructor( private val scheduler: Scheduler, + private val coroutineScope: CoroutineScope, jobListener: JobListener?, schedulerListener: SchedulerListener?, ) : SchedulerService { init { + if (!scheduler.isStarted) { + scheduler.start() + } if (jobListener != null && schedulerListener != null) { try { scheduler.listenerManager.addJobListener(jobListener) @@ -46,53 +52,67 @@ class SchedulerServiceImpl @Inject constructor( logger.warn("The Listeners could not be added to the scheduler", exc) } } else { - logger.warn("The Listeners cannot be null and will not be added to the scheduler") + logger.error("The Listeners cannot be null and should be added to the scheduler") } } override fun scheduleJob(jobDetail: JobDetail, trigger: Trigger) { - scheduler.scheduleJob(jobDetail, trigger) + coroutineScope.launch { + scheduler.scheduleJob(jobDetail, trigger) + } } override fun checkJobExists(jobKey: JobKey): Boolean { return scheduler.checkExists(jobKey) } + override fun checkTriggerExists(triggerKey: TriggerKey): Boolean { + return scheduler.checkExists(triggerKey) + } + override fun scheduleJobs(jobDetailTriggerMap: Map>) { - scheduler.scheduleJobs(jobDetailTriggerMap, true) + coroutineScope.launch { + scheduler.scheduleJobs(jobDetailTriggerMap, true) + } } override fun updateScheduledJob( - jobKey: JobKey, triggerKey: TriggerKey, jobDataMap: JobDataMap, associatedObject: Any? + jobKey: JobKey, + triggerKey: TriggerKey, + jobDataMap: JobDataMap, + associatedObject: Any?, ) { - require(scheduler.checkExists(jobKey)) { "The Specified Job Key does not exist : $jobKey" } + coroutineScope.launch { + require(checkJobExists(jobKey)) { "The Specified Job Key does not exist : $jobKey" } + require(checkTriggerExists(triggerKey)) { "The Specified Trigger Key does not exist :$triggerKey" } - require(scheduler.checkExists(triggerKey)) { "The Specified Trigger Key does not exist :$triggerKey" } + val jobDetail = scheduler.getJobDetail(jobKey) + jobDetail.jobDataMap.putAll(jobDataMap.wrappedMap) - val jobDetail = scheduler.getJobDetail(jobKey) - jobDetail.jobDataMap.putAll(jobDataMap.wrappedMap) + val trigger = scheduler.getTrigger(triggerKey) as SimpleTriggerImpl + trigger.jobDataMap.putAll(jobDataMap.wrappedMap) - val trigger = scheduler.getTrigger(triggerKey) as SimpleTriggerImpl - trigger.jobDataMap.putAll(jobDataMap.wrappedMap) + if (associatedObject is Scheduled) { + val scheduledTime = + requireNotNull(associatedObject.scheduledTime) { "The scheduled time cannot be null" } + trigger.setStartTime(Date(scheduledTime.toEpochMilli())) + } - if (associatedObject is Scheduled) { - val scheduledObject = associatedObject - trigger.setStartTime(Date(scheduledObject.scheduledTime!!.toEpochMilli())) + scheduler.addJob(jobDetail, true) + scheduler.rescheduleJob(triggerKey, trigger) } - - scheduler.addJob(jobDetail, true) - - scheduler.rescheduleJob(triggerKey, trigger) } override fun deleteScheduledJobs(jobKeys: List) { - // The scheduler.deleteJobs method does not unschedule jobs so using a deleteJob. - jobKeys.forEach(this::deleteScheduledJob) + // The scheduler::deleteJobs method does not unschedule jobs so using a deleteJob. + jobKeys.forEach(this@SchedulerServiceImpl::deleteScheduledJob) } override fun deleteScheduledJob(jobKey: JobKey) { - if (scheduler.checkExists(jobKey)) { - scheduler.deleteJob(jobKey) + coroutineScope.launch { + if (scheduler.checkExists(jobKey)) { + jobKey.let(scheduler::deleteJob) + } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/MessageSchedulerService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/MessageSchedulerService.kt new file mode 100644 index 000000000..890e7e8ef --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/MessageSchedulerService.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.service.questionnaire.schedule + +import jakarta.inject.Inject +import org.quartz.JobBuilder +import org.quartz.JobDataMap +import org.quartz.JobDetail +import org.quartz.JobKey +import org.quartz.SimpleScheduleBuilder +import org.quartz.Trigger +import org.quartz.TriggerBuilder +import org.quartz.TriggerKey +import org.radarbase.appserver.jersey.entity.DataMessage +import org.radarbase.appserver.jersey.entity.Message +import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.entity.User +import org.radarbase.appserver.jersey.fcm.downstream.FcmSender +import org.radarbase.appserver.jersey.service.quartz.MessageJob +import org.radarbase.appserver.jersey.service.quartz.MessageType +import org.radarbase.appserver.jersey.service.quartz.QuartzNamingStrategy +import org.radarbase.appserver.jersey.service.quartz.SchedulerService +import org.radarbase.appserver.jersey.service.quartz.SimpleQuartzNamingStrategy +import org.slf4j.LoggerFactory +import java.time.Instant +import java.util.Date + +@Suppress("unused") +class MessageSchedulerService @Inject constructor( + val fcmSender: FcmSender, + val schedulerService: SchedulerService, +) { + fun schedule(message: T) { + logger.debug("Scheduling message with id {}", message.id) + val jobDetail = getJobDetailForMessage(message, getMessageType(message)) + if (schedulerService.checkJobExists(jobDetail.key)) { + logger.info("Job with key {} has been scheduled already", { jobDetail.key }) + } else { + logger.debug("Job Detail = {}", jobDetail) + val trigger = getTriggerForMessage(message, jobDetail) + schedulerService.scheduleJob(jobDetail, trigger) + } + } + + fun scheduleMultiple(messages: List) { + val jobDetailSetMap: Map> = buildMap { + for (message in messages) { + val jobDetail = getJobDetailForMessage(message, getMessageType(message)) + + if (schedulerService.checkJobExists(jobDetail.key)) { + logger.info("Job with key {} is already scheduled", { jobDetail.key }) + continue + } + + setOf(getTriggerForMessage(message, jobDetail)).also { triggers -> + this.putIfAbsent(jobDetail, triggers) + } + } + } + logger.info("Scheduling {} messages", jobDetailSetMap.size) + schedulerService.scheduleJobs(jobDetailSetMap) + } + + fun updateScheduled(message: T) { + val (messageId: Long, subjectId: String) = nonNullMessageIdAndSubjectId(message) + val jobKeyString: String = NAMING_STRATEGY.getJobKeyName( + subjectId, + messageId.toString(), + ) + val jobKey = JobKey(jobKeyString) + val triggerKeyString: String = + NAMING_STRATEGY.getTriggerName( + subjectId, + messageId.toString(), + ) + val triggerKey = TriggerKey(triggerKeyString) + val jobDataMap = JobDataMap() + + schedulerService.updateScheduledJob(jobKey, triggerKey, jobDataMap, message) + } + + fun deleteScheduledMultiple(messages: List) { + messages.map { + val (messageId: Long, subjectId: String) = nonNullMessageIdAndSubjectId(it) + JobKey(NAMING_STRATEGY.getJobKeyName(subjectId, messageId.toString())) + } + .let(schedulerService::deleteScheduledJobs) + } + + fun deleteScheduled(message: T) { + JobKey(NAMING_STRATEGY.getJobKeyName(message.user!!.subjectId!!, message.id.toString())) + .let(schedulerService::deleteScheduledJob) + } + + fun getMessageType(message: T): MessageType = when (message) { + is Notification -> MessageType.NOTIFICATION + is DataMessage -> MessageType.DATA + else -> MessageType.UNKNOWN + } + + companion object { + private val logger = LoggerFactory.getLogger(MessageSchedulerService::class.java) + + val NAMING_STRATEGY: QuartzNamingStrategy = SimpleQuartzNamingStrategy() + + /** + * Build a Quartz [Trigger] that will fire exactly once at the message’s scheduled time. + * + * @param message the [Message] whose scheduling fields must be non-null + * @param jobDetail the [JobDetail] to which this trigger will be bound + * @return a one-shot [Trigger] that starts at `message.scheduledTime` + * @throws IllegalArgumentException if any of `message.id`, `message.user?.subjectId` or + * `message.scheduledTime` is null + */ + fun getTriggerForMessage(message: Message, jobDetail: JobDetail): Trigger { + val (messageId: Long, subjectId: String, scheduledTime: Instant) = nonNullTriggerUtils(message) + + return TriggerBuilder.newTrigger() + .withIdentity( + TriggerKey( + NAMING_STRATEGY.getTriggerName( + subjectId, + messageId.toString(), + ), + ), + ) + .forJob(jobDetail) + .startAt(Date(scheduledTime.toEpochMilli())) + .withSchedule( + SimpleScheduleBuilder.simpleSchedule() + .withRepeatCount(0) + .withIntervalInMilliseconds(0) + .withMisfireHandlingInstructionFireNow(), + ) + .build() + } + + /** + * Build a Quartz [JobDetail] that carries the message payload. + * + * @param message the [Message] whose fields must be non-null + * @param messageType the type of the message + * @return a durable [JobDetail] with its [JobDataMap] populated from `message` and `messageType` + * @throws IllegalArgumentException if any of `message.id`, `message.user?.subjectId`, + * `message.user?.project?.projectId` is null + */ + fun getJobDetailForMessage(message: Message, messageType: MessageType): JobDetail { + val (messageId: Long, subjectId: String, projectId: String) = nonNullJobUtils(message) + + val dataMap = JobDataMap( + mapOf( + "subjectId" to subjectId, + "projectId" to projectId, + "messageId" to messageId, + "messageType" to messageType.toString(), + ), + ) + + return JobBuilder.newJob(MessageJob::class.java) + .withIdentity( + JobKey( + NAMING_STRATEGY.getJobKeyName( + subjectId, + messageId.toString(), + ), + ), + ) + .withDescription("Send message at scheduled time...") + .setJobData(dataMap) + .storeDurably(true) + .build() + } + + /** + * Extract and validate the three mandatory scheduling fields from [message]. + * + * @return a [Triple] of `(messageId, subjectId, scheduledTime)` + * @throws IllegalArgumentException if any required field is null + */ + fun nonNullTriggerUtils(message: Message): Triple { + val (messageId: Long, subjectId: String) = nonNullMessageIdAndSubjectId(message) + val scheduledTime: Instant = requireNotNull(message.scheduledTime) { "Scheduled time cannot be null" } + + return Triple(messageId, subjectId, scheduledTime) + } + + /** + * Extract and validate the three mandatory job-creation fields from [message]. + * + * @return a [Triple] of `(messageId, subjectId, projectId)` + * @throws IllegalArgumentException if any required field is null + */ + fun nonNullJobUtils(message: Message): Triple { + val (messageId: Long, subjectId: String) = nonNullMessageIdAndSubjectId(message) + + val user: User = requireNotNull(message.user) { "User for message cannot be null" } + val projectId: String = requireNotNull( + requireNotNull(user.project) { "Project for user in message cannot be null" } + .projectId, + ) { "Project Id for user in message cannot be null" } + + return Triple(messageId, subjectId, projectId) + } + + /** + * Extract and validate the two mandatory common fields from [message]. + * + * @return a [Pair] of `(messageId, subjectId)` + * @throws IllegalArgumentException if `message.id` or `message.user?.subjectId` is null + */ + fun nonNullMessageIdAndSubjectId(message: Message): Pair { + val messageId: Long = requireNotNull(message.id) { "Message Id cannot be null" } + val subjectId: String = requireNotNull( + requireNotNull(message.user) { "User for message cannot be null" }.subjectId, + ) { "Subject Id in message cannot be null" } + + return Pair(messageId, subjectId) + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ProtocolHandlerRunner.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ProtocolHandlerRunner.kt similarity index 97% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ProtocolHandlerRunner.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ProtocolHandlerRunner.kt index 20f6aaf5e..7c8ade45f 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ProtocolHandlerRunner.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ProtocolHandlerRunner.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule +package org.radarbase.appserver.jersey.service.questionnaire.schedule import org.radarbase.appserver.jersey.dto.protocol.Assessment import org.radarbase.appserver.jersey.dto.questionnaire.AssessmentSchedule diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/QuestionnaireScheduleGeneratorService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleGeneratorService.kt similarity index 92% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/QuestionnaireScheduleGeneratorService.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleGeneratorService.kt index 9ab59c72c..8e83377c0 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/QuestionnaireScheduleGeneratorService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleGeneratorService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule +package org.radarbase.appserver.jersey.service.questionnaire.schedule import org.radarbase.appserver.jersey.dto.protocol.Assessment import org.radarbase.appserver.jersey.dto.protocol.AssessmentType @@ -84,15 +84,21 @@ class QuestionnaireScheduleGeneratorService : ScheduleGeneratorService { override fun getReminderHandler(assessment: Assessment): ProtocolHandler? { return if (assessment.type == AssessmentType.CLINICAL) { null - } else ReminderHandlerFactory.reminderHandler + } else { + ReminderHandlerFactory.reminderHandler + } } override fun getCompletedQuestionnaireHandler( - assessment: Assessment, prevTasks: List, prevTimezone: String + assessment: Assessment, + prevTasks: List, + prevTimezone: String, ): ProtocolHandler? { return if (assessment.type == AssessmentType.CLINICAL) { null - } else CompletedQuestionnaireHandlerFactory.getCompletedQuestionnaireHandler(prevTasks, prevTimezone) + } else { + CompletedQuestionnaireHandlerFactory.getCompletedQuestionnaireHandler(prevTasks, prevTimezone) + } } companion object { diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleService.kt new file mode 100644 index 000000000..213807587 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/QuestionnaireScheduleService.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.service.questionnaire.schedule + +import jakarta.inject.Inject +import org.radarbase.appserver.jersey.dto.protocol.Assessment +import org.radarbase.appserver.jersey.dto.protocol.AssessmentType +import org.radarbase.appserver.jersey.dto.protocol.Protocol +import org.radarbase.appserver.jersey.dto.questionnaire.AssessmentSchedule +import org.radarbase.appserver.jersey.dto.questionnaire.Schedule +import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.entity.Task +import org.radarbase.appserver.jersey.entity.User +import org.radarbase.appserver.jersey.repository.ProjectRepository +import org.radarbase.appserver.jersey.repository.UserRepository +import org.radarbase.appserver.jersey.search.TaskSpecificationsBuilder +import org.radarbase.appserver.jersey.service.FcmNotificationService +import org.radarbase.appserver.jersey.service.TaskService +import org.radarbase.appserver.jersey.service.github.protocol.ProtocolGenerator +import org.radarbase.appserver.jersey.service.scheduling.SchedulingService +import org.radarbase.appserver.jersey.utils.checkInvalidDetails +import org.radarbase.appserver.jersey.utils.checkPresence +import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.service.AsyncCoroutineService +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.sql.Timestamp +import java.time.Duration +import java.time.Instant + +@Suppress("unused") +class QuestionnaireScheduleService @Inject constructor( + private val protocolGenerator: ProtocolGenerator, + private val scheduleGeneratorService: ScheduleGeneratorService, + private val userRepository: UserRepository, + private val projectRepository: ProjectRepository, + private val taskService: TaskService, + private val notificationService: FcmNotificationService, + schedulingService: SchedulingService, + asyncService: AsyncCoroutineService, +) { + private val subjectScheduleMap: HashMap = hashMapOf() + + private val cleanScheduleRef: SchedulingService.RepeatReference = schedulingService.repeat( + Duration.ofMillis(3_600_000), + Duration.ofMillis(5_000), + ) { + asyncService.runBlocking { + generateAllSchedules() + } + } + + suspend fun getTasksUsingProjectIdAndSubjectId(subjectId: String, projectId: String): List { + return getTasksForUser(subjectAndProjectExistsElseThrow(subjectId, projectId)) + } + + suspend fun getTasksByTypeUsingProjectIdAndSubjectId( + projectId: String, + subjectId: String, + type: AssessmentType, + search: String, + ): List { + getSearchBuilder(projectId, subjectId, type, search).build().also { spec -> + return this.taskService.getTasksBySpecification(spec) + } + } + + suspend fun getTasksForDateUsingProjectIdAndSubjectId( + subjectId: String, + projectId: String, + startTime: Instant, + endTime: Instant, + ): List { + val user: User = subjectAndProjectExistsElseThrow(subjectId, projectId) + val tasks: MutableList = this.getTasksForUser(user).toMutableList() + + tasks.removeIf { task -> + val taskTime: Timestamp? = task.timestamp + checkNotNull(taskTime) { "Task timestamp cannot is null in questionnaire scheduler service." } + + val completionWindow: Long? = task.completionWindow + checkNotNull(completionWindow) { "Task completion window is null in questionnaire scheduler service." } + + taskTime.toInstant().let { taskTimeInstant -> + taskTimeInstant.plusMillis(completionWindow).isBefore(startTime) || taskTimeInstant.isAfter(endTime) + } + } + + return tasks + } + + suspend fun generateScheduleUsingProjectIdAndSubjectId(subjectId: String, projectId: String): Schedule { + return subjectAndProjectExistsElseThrow(subjectId, projectId).run { + generateScheduleForUser(this) + } + } + + suspend fun generateScheduleForUser(user: User): Schedule { + val subjectId: String? = user.subjectId + checkNotNull(subjectId) { "Subject ID cannot be null in questionnaire scheduler service." } + val protocol: Protocol? = protocolGenerator.getProtocolForSubject(subjectId) + + val newSchedule: Schedule = protocol?.let { + val prevSchedule: Schedule = getScheduleForSubject(subjectId) + val prevTimeZone: String = prevSchedule.timezone ?: checkNotNull(user.timezone) { + "User timezone cannot be null in questionnaire scheduler service." + } + + if ((prevSchedule.version != it.version) || (prevTimeZone != user.timezone)) { + removeScheduleForUser(user) + } + scheduleGeneratorService.generateScheduleForUser(user, it, prevSchedule) + } ?: Schedule() + + return newSchedule.also { + subjectScheduleMap[subjectId] = it + saveTasksAndNotifications(user, newSchedule.assessmentSchedules) + } + } + + suspend fun saveTasksAndNotifications(user: User, assessmentSchedules: List) { + assessmentSchedules.filterNotNull() + .filter(AssessmentSchedule::hasTasks) + .forEach { + val (tasks, notifications, reminders) = nonNullTasksNotificationsAndReminders( + it.tasks, + it.notifications, + it.reminders, + ) + + taskService.addTasks(tasks, user) + notificationService.addNotifications(notifications, user) + notificationService.addNotifications(reminders, user) + } + } + + suspend fun generateScheduleUsingProjectIdAndSubjectIdAndAssessment( + projectId: String, + subjectId: String, + assessment: Assessment, + ): Schedule { + val user: User = subjectAndProjectExistsElseThrow(subjectId, projectId) + val protocol: Protocol? = protocolGenerator.getProtocolForSubject(subjectId) + + checkInvalidDetails( + { protocol == null || !protocol.hasAssessment(assessment.name) }, + { "Assessment not found in protocol. Add assessment to protocol first" }, + ) + + val userTimeZone = user.timezone + checkNotNull(userTimeZone) { "User timezone cannot be null in questionnaire scheduler service." } + + val schedule = getScheduleForSubject(subjectId) + val assessmentSchedule = scheduleGeneratorService.generateSingleAssessmentSchedule( + assessment, + user, + emptyList(), + userTimeZone, + ) + + schedule.addAssessmentSchedule(assessmentSchedule) + + saveTasksAndNotifications(user, listOf(assessmentSchedule)) + + return schedule + } + + suspend fun generateAllSchedules() { + logger.info("Generating all schedules") + userRepository.findAll().also { users: List -> + users.forEach { + generateScheduleForUser(it) + } + } + } + + fun getScheduleForSubject(subjectId: String): Schedule { + val schedule: Schedule? = subjectScheduleMap[subjectId] + return schedule ?: Schedule() + } + + suspend fun getTasksForUser(user: User): List { + return taskService.getTasksByUser(user) + } + + suspend fun subjectAndProjectExistsElseThrow(subjectId: String, projectId: String): User { + return checkPresence(this.projectRepository.findByProjectId(projectId), "project_not_found") { + "Project with projectId $projectId not found. Please create the project first." + }.let { project -> + checkPresence( + this.userRepository.findBySubjectIdAndProjectId( + subjectId, + checkNotNull(project.id) { "Project ID cannot be null." }, + ), + "user_not_found", + ) { + "User with subjectId $subjectId not found. Please create the user first." + } + } + } + + suspend fun removeScheduleForUser(user: User) { + val userId = checkNotNull(user.id) { "User ID cannot be null." } + taskService.deleteTasksByUserId(userId) + } + + suspend fun removeScheduleForUserUsingSubjectIdAndType( + projectId: String, + subjectId: String, + type: AssessmentType, + search: String, + ) { + getSearchBuilder(projectId, subjectId, type, search).build().also { taskSpecification -> + taskService.deleteTasksBySpecification(taskSpecification) + } + } + + suspend fun getSearchBuilder( + projectId: String, + subjectId: String, + type: AssessmentType?, + search: String?, + ): TaskSpecificationsBuilder { + val builder = TaskSpecificationsBuilder() + + subjectAndProjectExistsElseThrow(subjectId, projectId).also { user -> + builder.with("user", ":", user) + } + + if (type != null && type != AssessmentType.ALL) { + builder.with("type", ":", type) + } + if (!search.isNullOrBlank()) { + search.split(COMMA_PATTERN).forEach { searchTerm: String -> + TASK_SEARCH_PATTERN.matchEntire(searchTerm.trim())?.also { matcher -> + val (field, operator, value) = matcher.destructured + builder.with(field, operator, value) + } + } + } + return builder + } + + companion object { + private val logger: Logger = LoggerFactory.getLogger(QuestionnaireScheduleService::class.java) + + private val TASK_SEARCH_PATTERN = Regex("(\\w+)([:<>])(\\w+)") + private val COMMA_PATTERN = Regex(",") + + fun nonNullTasksNotificationsAndReminders( + tasks: List?, + notifications: List?, + reminders: List?, + ): Triple, List, List> { + val nonNullTasks = requireNotNull(tasks) { "Tasks cannot be null" } + val nonNullNotifications = requireNotNull(notifications) { "Notifications cannot be null" } + val nonNullReminders = requireNotNull(reminders) { "Reminders cannot be null" } + + return Triple(nonNullTasks, nonNullNotifications, nonNullReminders) + } + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ScheduleGeneratorService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ScheduleGeneratorService.kt similarity index 98% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ScheduleGeneratorService.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ScheduleGeneratorService.kt index 192946fb6..ce370562c 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/ScheduleGeneratorService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/ScheduleGeneratorService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule +package org.radarbase.appserver.jersey.service.questionnaire.schedule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/NotificationType.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/NotificationType.kt similarity index 90% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/NotificationType.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/NotificationType.kt index face5ae37..7034ffbbb 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/NotificationType.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/NotificationType.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule.notification +package org.radarbase.appserver.jersey.service.questionnaire.schedule.notification enum class NotificationType { SOON, @@ -23,5 +23,5 @@ enum class NotificationType { MISSED_SOON, MISSED, TEST, - OTHER + OTHER, } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/TaskNotificationGeneratorService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/TaskNotificationGeneratorService.kt similarity index 92% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/TaskNotificationGeneratorService.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/TaskNotificationGeneratorService.kt index 1931df757..e954bfffd 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/notification/TaskNotificationGeneratorService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/notification/TaskNotificationGeneratorService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule.notification +package org.radarbase.appserver.jersey.service.questionnaire.schedule.notification import org.radarbase.appserver.jersey.dto.protocol.LanguageText import org.radarbase.appserver.jersey.entity.Notification @@ -23,8 +23,11 @@ import java.time.Instant class TaskNotificationGeneratorService { fun createNotification( - task: Task, notificationTimestamp: Instant, - title: String?, body: String?, emailEnabled: Boolean + task: Task, + notificationTimestamp: Instant, + title: String?, + body: String?, + emailEnabled: Boolean, ): Notification { return Notification.NotificationBuilder().apply { scheduledTime(notificationTimestamp) diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/task/TaskGeneratorService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/task/TaskGeneratorService.kt similarity index 95% rename from appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/task/TaskGeneratorService.kt rename to appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/task/TaskGeneratorService.kt index cffcf02ae..0761effec 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/task/TaskGeneratorService.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire/schedule/task/TaskGeneratorService.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.radarbase.appserver.jersey.service.questionnaire_schedule.task +package org.radarbase.appserver.jersey.service.questionnaire.schedule.task import org.radarbase.appserver.jersey.dto.protocol.Assessment import org.radarbase.appserver.jersey.dto.protocol.AssessmentType diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/MessageSchedulerService.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/MessageSchedulerService.kt deleted file mode 100644 index 487ecec5b..000000000 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/questionnaire_schedule/MessageSchedulerService.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2025 King's College London - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.radarbase.appserver.jersey.service.questionnaire_schedule - -import org.quartz.JobBuilder -import org.quartz.JobDataMap -import org.quartz.JobDetail -import org.quartz.JobKey -import org.quartz.SimpleScheduleBuilder -import org.quartz.Trigger -import org.quartz.TriggerBuilder -import org.quartz.TriggerKey -import org.radarbase.appserver.jersey.entity.DataMessage -import org.radarbase.appserver.jersey.entity.Message -import org.radarbase.appserver.jersey.entity.Notification -import org.radarbase.appserver.jersey.fcm.downstream.FcmSender -import org.radarbase.appserver.jersey.service.quartz.MessageJob -import org.radarbase.appserver.jersey.service.quartz.MessageType -import org.radarbase.appserver.jersey.service.quartz.QuartzNamingStrategy -import org.radarbase.appserver.jersey.service.quartz.SchedulerService -import org.radarbase.appserver.jersey.service.quartz.SimpleQuartzNamingStrategy -import org.slf4j.LoggerFactory -import java.util.Date - -class MessageSchedulerService( - val fcmSender: FcmSender, - val schedulerService: SchedulerService, -) { - fun schedule(message: T) { - val jobDetail = getJobDetailForMessage(message, getMessageType(message)) - if (schedulerService.checkJobExists(jobDetail.key)) { - println("Job has been scheduled already.") - } else { - val trigger = getTriggerForMessage(message, jobDetail) - schedulerService.scheduleJob(jobDetail, trigger) - } - } - - fun scheduleMultiple(messages: List) { - val jobDetailSetMap = mutableMapOf>() - for (message in messages) { - val jobDetail = getJobDetailForMessage(message, getMessageType(message)) - - if (schedulerService.checkJobExists(jobDetail.key)) { - continue - } - val triggerSet = setOf(getTriggerForMessage(message, jobDetail)) - jobDetailSetMap[jobDetail] = triggerSet - } - schedulerService.scheduleJobs(jobDetailSetMap.toMap()) - } - - fun updateScheduled(message: T) { - val jobKeyString: String = - NAMING_STRATEGY.getJobKeyName( - message.user!!.subjectId!!, message.id.toString(), - ) - val jobKey = JobKey(jobKeyString) - val triggerKeyString: String = - NAMING_STRATEGY.getTriggerName( - message.user!!.subjectId!!, message.id.toString(), - ) - val triggerKey = TriggerKey(triggerKeyString) - val jobDataMap = JobDataMap() - - schedulerService.updateScheduledJob(jobKey, triggerKey, jobDataMap, message) - } - - fun deleteScheduledMultiple(messages: List) { - val keys = messages.map { - JobKey(NAMING_STRATEGY.getJobKeyName(it.user!!.subjectId!!, it.id.toString())) - } - schedulerService.deleteScheduledJobs(keys) - } - - - fun deleteScheduled(message: T) { - val key = JobKey(NAMING_STRATEGY.getJobKeyName(message.user!!.subjectId!!, message.id.toString())) - schedulerService.deleteScheduledJob(key) - } - - fun getMessageType(message: T): MessageType = when (message) { - is Notification -> MessageType.NOTIFICATION - is DataMessage -> MessageType.DATA - else -> MessageType.UNKNOWN - } - - companion object { - - private val log = LoggerFactory.getLogger(MessageSchedulerService::class.java) - - // TODO add a schedule cache to cache incoming requests - val NAMING_STRATEGY: QuartzNamingStrategy = SimpleQuartzNamingStrategy() - - fun getTriggerForMessage(message: Message, jobDetail: JobDetail): Trigger { - val triggerKey = TriggerKey( - NAMING_STRATEGY.getTriggerName( - message.user!!.subjectId!!, message.id.toString(), - ), - ) - - return TriggerBuilder.newTrigger() - .withIdentity(triggerKey) - .forJob( - NAMING_STRATEGY.getJobKeyName( - message.user!!.subjectId!!, message.id.toString(), - ), - ) - .startAt(Date(message.scheduledTime!!.toEpochMilli())) - .withSchedule( - SimpleScheduleBuilder.simpleSchedule() - .withRepeatCount(0) - .withIntervalInMilliseconds(0) - .withMisfireHandlingInstructionFireNow(), - ) - .build() - } - - fun getJobDetailForMessage(message: Message, messageType: MessageType): JobDetail { - val jobKey = JobKey( - NAMING_STRATEGY.getJobKeyName( - message.user!!.subjectId!!, message.id.toString(), - ), - ) - - val dataMap = JobDataMap( - mapOf( - "subjectId" to message.user!!.subjectId, - "projectId" to message.user!!.project!!.projectId, - "messageId" to message.id, - "messageType" to messageType.toString(), - ), - ) - - return JobBuilder.newJob(MessageJob::class.java) - .withIdentity(jobKey) - .withDescription("Send message at scheduled time...") - .setJobData(dataMap) - .storeDurably(true) // equivalent to setDurability(true) - .build() - } - } -} - diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/DataMessageTransmitter.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/DataMessageTransmitter.kt index b7f1726b9..8a9101852 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/DataMessageTransmitter.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/DataMessageTransmitter.kt @@ -21,5 +21,5 @@ import org.radarbase.appserver.jersey.exception.MessageTransmitException interface DataMessageTransmitter { @Throws(MessageTransmitException::class) - fun send(dataMessage: DataMessage) + suspend fun send(dataMessage: DataMessage) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/FcmTransmitter.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/FcmTransmitter.kt index 9bebdf1c3..73b7b01ee 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/FcmTransmitter.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/FcmTransmitter.kt @@ -16,20 +16,112 @@ package org.radarbase.appserver.jersey.service.transmitter +import com.google.firebase.ErrorCode +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.MessagingErrorCode +import jakarta.inject.Inject +import org.radarbase.appserver.jersey.dto.fcm.FcmUserDto import org.radarbase.appserver.jersey.entity.DataMessage +import org.radarbase.appserver.jersey.entity.Message import org.radarbase.appserver.jersey.entity.Notification +import org.radarbase.appserver.jersey.exception.FcmMessageTransmitException +import org.radarbase.appserver.jersey.fcm.downstream.FcmSender import org.radarbase.appserver.jersey.fcm.model.FcmDataMessage import org.radarbase.appserver.jersey.fcm.model.FcmNotificationMessage +import org.radarbase.appserver.jersey.service.FcmDataMessageService +import org.radarbase.appserver.jersey.service.FcmNotificationService +import org.radarbase.appserver.jersey.service.UserService +import org.radarbase.appserver.jersey.utils.requireNotNullField import org.slf4j.LoggerFactory import java.util.Objects -class FcmTransmitter : DataMessageTransmitter, NotificationTransmitter { - override fun send(dataMessage: DataMessage) { - TODO("Not yet implemented") +class FcmTransmitter @Inject constructor( + private val fcmSender: FcmSender, + private val notificationService: FcmNotificationService, + private val dataMessageService: FcmDataMessageService, + private val userService: UserService, +) : DataMessageTransmitter, NotificationTransmitter { + + override suspend fun send(dataMessage: DataMessage) { + try { + fcmSender.send(createMessageFromDataMessage(dataMessage)) + } catch (exc: FirebaseMessagingException) { + handleFcmException(exc, dataMessage) + } catch (exc: Exception) { + throw FcmMessageTransmitException("Could not transmit a data message through Fcm. ${exc.message}") + } + } + + override suspend fun send(notification: Notification) { + try { + fcmSender.send(createMessageFromNotification(notification)) + } catch (exc: FirebaseMessagingException) { + handleFcmException(exc, notification) + } catch (exc: Exception) { + throw FcmMessageTransmitException("Could not transmit a notification through Fcm. ${exc.message}") + } + } + + private suspend fun handleFcmException(exc: FirebaseMessagingException, message: Message?) { + logger.error("Error occurred when sending downstream message.", exc) + if (message != null) { + handleErrorCode(exc.errorCode, message) + handleFCMErrorCode(exc.messagingErrorCode, message) + } } - override fun send(notification: Notification) { - TODO("Not yet implemented") + @Suppress("UNUSED_PARAMETER") + fun handleErrorCode(errorCode: ErrorCode, message: Message?) { + // More info on ErrorCode: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode + when (errorCode) { + ErrorCode.INVALID_ARGUMENT, + ErrorCode.INTERNAL, + ErrorCode.ABORTED, + ErrorCode.CONFLICT, + ErrorCode.CANCELLED, + ErrorCode.DATA_LOSS, + ErrorCode.NOT_FOUND, + ErrorCode.OUT_OF_RANGE, + ErrorCode.ALREADY_EXISTS, + ErrorCode.DEADLINE_EXCEEDED, + ErrorCode.PERMISSION_DENIED, + ErrorCode.RESOURCE_EXHAUSTED, + ErrorCode.FAILED_PRECONDITION, + ErrorCode.UNAUTHENTICATED, + ErrorCode.UNKNOWN, + -> {} + ErrorCode.UNAVAILABLE -> { + // Could schedule for retry. + logger.warn("The FCM service is unavailable") + } + } + } + + private suspend fun handleFCMErrorCode(errorCode: MessagingErrorCode, message: Message) { + when (errorCode) { + MessagingErrorCode.INTERNAL, MessagingErrorCode.QUOTA_EXCEEDED, MessagingErrorCode.INVALID_ARGUMENT, MessagingErrorCode.SENDER_ID_MISMATCH, MessagingErrorCode.THIRD_PARTY_AUTH_ERROR -> {} + MessagingErrorCode.UNAVAILABLE -> { + // Could schedule for retry. + logger.warn("The FCM service is unavailable.") + } + + MessagingErrorCode.UNREGISTERED -> { + val userDto = FcmUserDto(requireNotNull(message.user) { "User cannot be null" }) + val subjectId = requireNotNullField(userDto.subjectId, "Subject Id") + val projectId = requireNotNullField(userDto.projectId, "Project Id") + + logger.warn("The Device for user {} was unregistered.", userDto.subjectId) + notificationService.removeNotificationsForUser( + projectId, + subjectId, + ) + dataMessageService.removeDataMessagesForUser( + projectId, + subjectId, + ) + userService.checkFcmTokenExistsAndReplace(userDto) + } + } } companion object { @@ -40,7 +132,8 @@ class FcmTransmitter : DataMessageTransmitter, NotificationTransmitter { private fun createMessageFromNotification(notification: Notification): FcmNotificationMessage { val to = Objects.requireNonNullElseGet( - notification.fcmTopic, notification.user!!::fcmToken, + notification.fcmTopic, + requireNotNullField(notification.user, "Notification's User")::fcmToken, ) return FcmNotificationMessage().apply { this.to = to @@ -48,17 +141,18 @@ class FcmTransmitter : DataMessageTransmitter, NotificationTransmitter { this.priority = notification.priority this.mutableContent = notification.mutableContent this.deliveryReceiptRequested = IS_DELIVERY_RECEIPT_REQUESTED + this.data = notification.additionalData this.messageId = notification.fcmMessageId.toString() - this.timeToLive = Objects.requireNonNullElse(notification.ttlSeconds, DEFAULT_TIME_TO_LIVE) + this.timeToLive = Objects.requireNonNullElse(notification.ttlSeconds, DEFAULT_TIME_TO_LIVE) this.notification = getNotificationMap(notification) - this.data = notification.additionalData } } private fun createMessageFromDataMessage(dataMessage: DataMessage): FcmDataMessage { val to = Objects.requireNonNullElseGet( - dataMessage.fcmTopic, dataMessage.user!!::fcmToken, + dataMessage.fcmTopic, + requireNotNullField(dataMessage.user, "Data Message's User")::fcmToken, ) return FcmDataMessage().apply { this.to = to @@ -73,10 +167,10 @@ class FcmTransmitter : DataMessageTransmitter, NotificationTransmitter { } private fun getNotificationMap(notification: Notification): Map { - val notificationMap: MutableMap = HashMap() - notificationMap.put("body", notification.body ?: "") - notificationMap.put("title", notification.title!!) - notificationMap.put("sound", "default") + val notificationMap: MutableMap = HashMap() + notificationMap["body"] = notification.body ?: "" + notificationMap["title"] = requireNotNullField(notification.title, "Notification's Title") + notificationMap["sound"] = "default" putIfNotNull(notificationMap, "sound", notification.sound) putIfNotNull(notificationMap, "badge", notification.badge) @@ -96,10 +190,8 @@ class FcmTransmitter : DataMessageTransmitter, NotificationTransmitter { fun putIfNotNull(map: MutableMap, key: String, value: Any?) { if (value != null) { - map.put(key, value) + map[key] = value } } - - } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/NotificationTransmitter.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/NotificationTransmitter.kt index 3fff1cd69..f4bed6cef 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/NotificationTransmitter.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/service/transmitter/NotificationTransmitter.kt @@ -21,5 +21,5 @@ import org.radarbase.appserver.jersey.exception.MessageTransmitException interface NotificationTransmitter { @Throws(MessageTransmitException::class) - fun send(notification: Notification) + suspend fun send(notification: Notification) } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Coroutines.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Coroutines.kt index fa0a11684..0111ca935 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Coroutines.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Coroutines.kt @@ -55,4 +55,3 @@ suspend inline fun Iterable.flatMapParallel( }.awaitAll() .flatten() } - diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Paths.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Paths.kt new file mode 100644 index 000000000..b2f1521b2 --- /dev/null +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Paths.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.appserver.jersey.utils + +object Paths { + const val PROJECTS_PATH = "projects" + const val USERS_PATH = "users" + const val PROJECT_ID = "{projectId}" + const val SUBJECT_ID = "{subjectId}" + const val NOTIFICATION_ID = "{notificationId}" + const val MESSAGING_DATA_PATH = "messaging/data" + const val MESSAGING_NOTIFICATION_PATH = "messaging/notifications" + const val NOTIFICATION_STATE_EVENTS_PATH = "state_events" + const val QUESTIONNAIRE_STATE_EVENTS_PATH = "state_events" + const val GITHUB_PATH = "github" + const val GITHUB_CONTENT_PATH = "content" + const val PROTOCOLS_PATH = "protocols" + const val TASK_ID = "{taskId}" + const val QUESTIONNAIRE_SCHEDULE = "questionnaire/schedule" + const val ALL_KEYWORD = "all" +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Utils.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Utils.kt index e6ab70d20..7558efa64 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Utils.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/Utils.kt @@ -14,32 +14,16 @@ * limitations under the License. */ -/* - * - * * - * * * Copyright 2018 King's College London - * * * - * * * Licensed under the Apache License, Version 2.0 (the "License"); - * * * you may not use this file except in compliance with the License. - * * * You may obtain a copy of the License at - * * * - * * * http://www.apache.org/licenses/LICENSE-2.0 - * * * - * * * Unless required by applicable law or agreed to in writing, software - * * * distributed under the License is distributed on an "AS IS" BASIS, - * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * * * See the License for the specific language governing permissions and - * * * limitations under the License. - * * * - * * - * - */ - package org.radarbase.appserver.jersey.utils +import jakarta.inject.Provider import org.radarbase.appserver.jersey.dto.ProjectDto import org.radarbase.appserver.jersey.exception.InvalidProjectDetailsException +import org.radarbase.auth.token.DataRadarToken +import org.radarbase.auth.token.RadarToken +import org.radarbase.jersey.exception.HttpForbiddenException import org.radarbase.jersey.exception.HttpNotFoundException +import org.radarbase.jersey.service.AsyncCoroutineService import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -54,12 +38,14 @@ inline fun checkPresence(value: T?, code: String, messageProvider: () } if (value == null) { - throw HttpNotFoundException(code,messageProvider()) + throw HttpNotFoundException(code, messageProvider()) } else { return value } } +fun requireNotNullField(value: T?, fieldName: String): T = checkNotNull(value) { "$fieldName cannot be null" } + /** * Validates a condition for the given project details and throws [InvalidProjectDetailsException] * if the condition is met (Project details are invalid). @@ -69,6 +55,7 @@ inline fun checkPresence(value: T?, code: String, messageProvider: () * @param messageProvider a lambda providing the error message for the exception * @throws InvalidProjectDetailsException if the validation fails */ +@Suppress("UNUSED_PARAMETER") inline fun checkInvalidProjectDetails( projectDTO: ProjectDto, invalidation: () -> Boolean, @@ -100,3 +87,40 @@ inline fun checkInvalidDetails( ).newInstance(messageProvider()) } } + +/** + * Throws an exception of type [E] if the given [shouldInvalidate] is true. + * + * After this function returns normally, Kotlin’s flow analysis knows that + * `shouldInvalidate` is false, so any value checked by it can be treated + * as “valid” (eg: non‑null). + * + * @param shouldInvalidate A Boolean expression: if true, an [E] is thrown. + * @param messageProvider Supplies the exception message when invalidation occurs. + * @throws E if [shouldInvalidate] is true. + */ +@OptIn(ExperimentalContracts::class) +inline fun checkInvalidDetails( + shouldInvalidate: Boolean, + messageProvider: () -> String, +) { + contract { + returns() implies (!shouldInvalidate) + } + if (shouldInvalidate) { + throw E::class.java + .getDeclaredConstructor(String::class.java) + .newInstance(messageProvider()) + } +} + +suspend inline fun tokenForCurrentRequest( + asyncService: AsyncCoroutineService, + tokenProvider: Provider, +): RadarToken = asyncService.runInRequestScope { + try { + DataRadarToken(tokenProvider.get()) + } catch (_: Throwable) { + throw HttpForbiddenException("unauthorized", "User without authentication does not have permission.") + } +} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/annotation/CheckExactlyOneNotNull.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/annotation/CheckExactlyOneNotNull.kt index 5da001235..e19f30aa6 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/annotation/CheckExactlyOneNotNull.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/annotation/CheckExactlyOneNotNull.kt @@ -19,10 +19,10 @@ package org.radarbase.appserver.jersey.utils.annotation import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator import jakarta.validation.ConstraintValidatorContext -import kotlin.annotation.AnnotationRetention.RUNTIME -import kotlin.annotation.AnnotationTarget.CLASS import java.beans.Introspector import java.beans.PropertyDescriptor +import kotlin.annotation.AnnotationRetention.RUNTIME +import kotlin.annotation.AnnotationTarget.CLASS @Target(CLASS) @Retention(RUNTIME) @@ -55,7 +55,6 @@ annotation class CheckExactlyOneNotNull( } catch (_: Exception) { false } - } } } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedFunction.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedFunction.kt index 170bc78df..a07aad837 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedFunction.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedFunction.kt @@ -14,27 +14,6 @@ * limitations under the License. */ -/* - * - * * - * * * Copyright 2018 King's College London - * * * - * * * Licensed under the Apache License, Version 2.0 (the "License"); - * * * you may not use this file except in compliance with the License. - * * * You may obtain a copy of the License at - * * * - * * * http://www.apache.org/licenses/LICENSE-2.0 - * * * - * * * Unless required by applicable law or agreed to in writing, software - * * * distributed under the License is distributed on an "AS IS" BASIS, - * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * * * See the License for the specific language governing permissions and - * * * limitations under the License. - * * * - * * - * - */ - package org.radarbase.appserver.jersey.utils.cache import kotlinx.coroutines.sync.Mutex diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedMap.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedMap.kt index bb63088e5..75a86e8ea 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedMap.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/CachedMap.kt @@ -16,7 +16,6 @@ package org.radarbase.appserver.jersey.utils.cache -import kotlinx.coroutines.runBlocking import org.radarbase.appserver.jersey.utils.cache.deps.AtomicNonNullReference import java.io.IOException import java.time.Duration @@ -28,7 +27,7 @@ import java.time.Instant * This class is thread-safe if the given supplier is thread-safe. */ @Suppress("unused") -class CachedMap ( +class CachedMap( private val supplier: ThrowingSupplier>, private val invalidateAfter: Duration, private val retryAfter: Duration, @@ -83,13 +82,11 @@ class CachedMap ( * @throws IOException if the cache cannot be refreshed. */ @Throws(IOException::class) - operator fun get(key: S): T? { + suspend fun getByKey(key: S): T? { val currentResult: Result = cache.get() val value: T? = currentResult.map[key] return if (value == null && currentResult.isStale(retryAfter)) { - runBlocking { - get(true)[key] - } + get(true)[key] } else { value } diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/deps/CustomThrowingFunction.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/deps/CustomThrowingFunction.kt index 07f9875fc..4e0cc1a61 100644 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/deps/CustomThrowingFunction.kt +++ b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/cache/deps/CustomThrowingFunction.kt @@ -29,7 +29,7 @@ fun interface CustomThrowingFunction { /** * Applies the given transformation logic on the input of type T and returns a result of type R. * - * @param t The input parameter of type T on which the operation is to be applied. + * @param key The input parameter of type T on which the operation is to be applied. * @return The result of the operation as an instance of type R. * @throws Exception If an exception occurs during the application of the transformation. */ diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/Base64Deserializer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/Base64Deserializer.kt deleted file mode 100644 index 161f14760..000000000 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/Base64Deserializer.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2025 King's College London - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.radarbase.appserver.jersey.utils.deserializer - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.BeanProperty -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.deser.ContextualDeserializer -import com.fasterxml.jackson.databind.exc.InvalidFormatException -import java.util.Base64 - -class Base64Deserializer : JsonDeserializer(), ContextualDeserializer { - override fun createContextual( - context: DeserializationContext, - property: BeanProperty, - ): JsonDeserializer<*> { - if (!String::class.java.isAssignableFrom(property.type.rawClass)) { - throw context.invalidTypeIdException( - property.type, - "String", - "Base64 decoding is only applied to String fields.", - ) - } - return this - } - - override fun deserialize(parser: JsonParser, context: DeserializationContext): String { - val value = clean(parser.valueAsString) - val decoder = Base64.getDecoder() - - return try { - val decodedValue = decoder.decode(value) - String(decodedValue) - } catch (_: IllegalArgumentException) { - val fieldName = parser.parsingContext.currentName - val wrapperClass = parser.parsingContext.currentValue.javaClass - - throw InvalidFormatException( - parser, - "Value for '$fieldName' is not a base64 encoded JSON", - value, - wrapperClass, - ) - } - } - - private fun clean(value: String): String { - return value.replace(Regex("[\n\r]"), "") - } -} diff --git a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/ReferenceTimestampDeserializer.kt b/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/ReferenceTimestampDeserializer.kt deleted file mode 100644 index e76b3d2d2..000000000 --- a/appserver-jersey/src/main/kotlin/org/radarbase/appserver/jersey/utils/deserializer/ReferenceTimestampDeserializer.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2025 King's College London - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.radarbase.appserver.jersey.utils.deserializer - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.ObjectMapper -import org.radarbase.appserver.jersey.dto.protocol.ReferenceTimestamp -import org.radarbase.appserver.jersey.dto.protocol.ReferenceTimestampType - -class ReferenceTimestampDeserializer : JsonDeserializer() { - override fun deserialize(parser: JsonParser, context: DeserializationContext): ReferenceTimestamp { - return if (parser.currentToken == JsonToken.START_OBJECT) { - val mapper = ObjectMapper() - mapper.readValue(parser, ReferenceTimestamp::class.java) - } else { - ReferenceTimestamp(parser.valueAsString, ReferenceTimestampType.DATETIMEUTC) - } - } -} diff --git a/appserver-jersey/src/main/resources/appserver.yml b/appserver-jersey/src/main/resources/appserver.yml index f8df08ea2..90d4beff9 100644 --- a/appserver-jersey/src/main/resources/appserver.yml +++ b/appserver-jersey/src/main/resources/appserver.yml @@ -1,16 +1,30 @@ resourceConfig: org.radarbase.appserver.jersey.enhancer.factory.AppserverResourceEnhancerFactory server: - baseUri: http://0.0.0.0:8090/ + baseUri: http://0.0.0.0:8080/ + requestTimeout: 30 + isJmxEnabled: false auth: managementPortalUrl: http://localhost:8081/managementportal - resourceName: res_appserver + resourceName: res_appconfig db: - jdbcDriver: org.h2.Driver - jdbcUrl: jdbc:h2:mem:dev - hibernateDialect: org.hibernate.dialect.H2Dialect + # For Development environment, use H2 database. + # jdbcDriver: org.h2.Driver + # jdbcUrl: jdbc:h2:mem:dev + # hibernateDialect: org.hibernate.dialect.H2Dialect + # liquibase: + # enabled: false + # additionalProperties: + # jakarta.persistence.schema-generation.database.action: drop-and-create + # hibernate.show_sql: true + # hibernate.format_sql: true + jdbcDriver: org.postgresql.Driver + jdbcUrl: jdbc:postgresql://localhost:5432/appserver + hibernateDialect: org.hibernate.dialect.PostgreSQLDialect + username: radar + password: radar github: cache: @@ -22,6 +36,17 @@ github: timeoutSec: 10 githubToken: +quartz: + # Specifies the dispatcher for asynchronous operations. + # Options: 'io' (for I/O-bound tasks), 'default' (for CPU-bound tasks), 'unconfined' (uses calling thread). + # Defaults to 'unconfined' if not provided. + coroutineDispatcher: io + + # Specifies the type of coroutine job. + # Options: 'supervisor-job' (supervises child jobs), 'coroutine-job' (no supervision). + # Defaults to 'supervisor-job' if not provided. + coroutineJob: supervisor-job + protocol: githubProtocolRepo: RADAR-base/RADAR-aRMT-protocols protocolFileName: protocol.json diff --git a/appserver-legacy/build.gradle.kts b/appserver-legacy/build.gradle.kts index c2e345831..6d04996ae 100644 --- a/appserver-legacy/build.gradle.kts +++ b/appserver-legacy/build.gradle.kts @@ -3,11 +3,11 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { eclipse -// scala + scala kotlin("kapt") kotlin("plugin.allopen") kotlin("plugin.noarg") -// id("io.gatling.gradle") version Versions.gatlingVersion + id("io.gatling.gradle") version Versions.gatlingVersion id("org.springframework.boot") version Versions.springBootVersion id("io.spring.dependency-management") version Versions.springDependencyManagementVersion id("org.radarbase.radar-dependency-management") @@ -57,24 +57,6 @@ val integrationTestImplementation: Configuration by configurations.getting { val integrationTestRuntimeOnly: Configuration by configurations.getting configurations["integrationTestRuntimeOnly"].extendsFrom(configurations.runtimeOnly.get()) -// -// kotlin { -// compilerOptions { -// jvmTarget.set(JvmTarget.JVM_17) -// languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) -// apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) -// } -// } - -// java { -// toolchain { -// languageVersion.set(JavaLanguageVersion.of(17)) -// } -// } - -// tasks.withType().configureEach { -// jvmTargetValidationMode.set(JvmTargetValidationMode.ERROR) -// } radarDependencies { rejectMajorVersionUpdates.set(true) @@ -87,6 +69,7 @@ radarKotlin { junitVersion.set(Versions.junit5Version) } +// TODO: Need to use a custom gradle plugin defined for this in buildSrc // integrationTestConfig { // sourceSetName = "IntegrationTest" // duplicatesStrategy = DuplicatesStrategy.EXCLUDE @@ -151,7 +134,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutinesVersion}") testImplementation("org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlinVersion}") -// testImplementation("io.gatling.highcharts:gatling-charts-highcharts:3.9.2") + testImplementation("io.gatling.highcharts:gatling-charts-highcharts:3.9.2") implementation("org.liquibase.ext:liquibase-hibernate6:4.20.0") @@ -166,7 +149,7 @@ dependencies { testImplementation("org.junit.platform:junit-platform-launcher:1.8.2") testImplementation("org.junit.platform:junit-platform-engine:1.8.2") -// gatlingImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + gatlingImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") } ktlint { @@ -229,30 +212,3 @@ tasks.register("unpack") { into(layout.buildDirectory.dir("dependency")) } -// tasks.register("downloadDependencies") { -// description = "Pre download dependencies" -// -// doLast { -// configurations.compileClasspath.get().files -// configurations.runtimeClasspath.get().files -// } -// } -// -// tasks.register("copyDependencies") { -// from(configurations.runtimeClasspath) -// into(layout.buildDirectory.dir("third-party")) -// } - -val isNonStable: (String) -> Boolean = { version: String -> - val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { - version.uppercase().contains(it) - } - val regex = Regex("^[0-9,.v-]+(-r)?$") - !stableKeyword && !(regex.matches(version)) -} - -// tasks.named("dependencyUpdates").configure { -// rejectVersionIf { -// isNonStable(candidate.version) -// } -// } diff --git a/appserver-legacy/src/main/kotlin/org/radarbase/appserver/exception/AlreadyExistsException.kt b/appserver-legacy/src/main/kotlin/org/radarbase/appserver/exception/AlreadyExistsException.kt index be1957970..562a1524f 100644 --- a/appserver-legacy/src/main/kotlin/org/radarbase/appserver/exception/AlreadyExistsException.kt +++ b/appserver-legacy/src/main/kotlin/org/radarbase/appserver/exception/AlreadyExistsException.kt @@ -31,7 +31,7 @@ import java.io.Serial * [HttpStatus.ALREADY_REPORTED]. * */ -@ResponseStatus(HttpStatus.ALREADY_REPORTED) +@ResponseStatus(HttpStatus.EXPECTATION_FAILED) class AlreadyExistsException : RuntimeException { companion object { @Serial diff --git a/appserver-legacy/src/main/kotlin/org/radarbase/appserver/service/TaskService.kt b/appserver-legacy/src/main/kotlin/org/radarbase/appserver/service/TaskService.kt index f4d0eabcf..1306d54b3 100644 --- a/appserver-legacy/src/main/kotlin/org/radarbase/appserver/service/TaskService.kt +++ b/appserver-legacy/src/main/kotlin/org/radarbase/appserver/service/TaskService.kt @@ -51,7 +51,7 @@ class TaskService( private val eventPublisher: ApplicationEventPublisher?, ) { @Transactional(readOnly = true) - fun getAllProjects(): List { + fun getAllTasks(): List { return taskRepository.findAll() } diff --git a/build.gradle.kts b/build.gradle.kts index 91b14d6bf..4d107834a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,12 +3,10 @@ plugins { id("org.radarbase.appserver-conventions") id("org.radarbase.radar-dependency-management") version Versions.radarCommonsVersion apply false id("org.radarbase.radar-kotlin") version Versions.radarCommonsVersion apply false - id("org.jetbrains.kotlin.plugin.spring") version Versions.kotlinVersion id("org.jetbrains.kotlin.plugin.jpa") version Versions.kotlinVersion kotlin("plugin.allopen") version Versions.kotlinVersion kotlin("plugin.noarg") version Versions.kotlinVersion - // id("com.avast.gradle.docker-compose") version Versions.dockerCompose apply false }