diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c8d574 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.gradle +build +out +.idea +.git +*.iml +docker-compose*.yml +README.md \ No newline at end of file diff --git a/.env.example b/.env.example deleted file mode 100644 index ea5a97e..0000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -############################################ -# Task Scheduler - Task Scheduler module -# Required Environment Variables -# -# Note: use double quotes only if the value contains spaces or starts with '#'. -############################################ - -# Secret key used to sign and verify JWT tokens -TS_JWT_SECRET=your-secret-key \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..4ab8b8e --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,81 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - master + +permissions: + contents: read + packages: write + +jobs: + build-publish: + runs-on: ubuntu-latest + + outputs: + image_tag: ${{ steps.meta.outputs.sha_tag }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Java 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Grant execute permission for Gradle + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test + + - name: Set image metadata + id: meta + run: | + SHORT_SHA=${GITHUB_SHA::7} + echo "sha_tag=sha-${SHORT_SHA}" >> $GITHUB_OUTPUT + echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push multi-arch image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/enzobbom/ts-task:master + ghcr.io/enzobbom/ts-task:${{ steps.meta.outputs.sha_tag }} + labels: | + org.opencontainers.image.source=https://github.com/enzobbom/ts-task + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Trigger ts-system + env: + GH_TOKEN: ${{ secrets.TS_SYSTEM_PAT }} + IMAGE_TAG: ${{ steps.meta.outputs.sha_tag }} + run: | + curl -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + https://api.github.com/repos/enzobbom/ts-system/dispatches \ + -d "$(jq -n \ + --arg service "task" \ + --arg tag "${IMAGE_TAG}" \ + '{event_type:"update-image",client_payload:{service:$service,tag:$tag}}')" \ No newline at end of file diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml deleted file mode 100644 index 61eae46..0000000 --- a/.github/workflows/gradle.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: PR Workflow - -on: - pull_request: - branches: - - master - types: - - opened - - synchronize - - reopened - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' - - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build with Gradle - run: ./gradlew build - - - name: Run tests - run: ./gradlew test diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..129794a --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,34 @@ +name: PR Check + +on: + pull_request: + branches: + - master + types: + - opened + - synchronize + - reopened + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3d8d515..a0062bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,37 @@ -# Basic Docker file -#FROM eclipse-temurin:17-jdk-jammy -#WORKDIR /app -#COPY build/libs/task-scheduler-0.0.1-SNAPSHOT.jar /app/task-scheduler.jar -#EXPOSE 8081 -#CMD ["java", "-jar", "/app/task-scheduler.jar"] - -# Build +### Build stage + +# creates a build image stage with Gradle and JDK FROM gradle:jdk17 AS build + +# sets the working directory inside the image stage WORKDIR /app -COPY . . -# Run .jar -RUN gradle build --no-daemon -FROM eclipse-temurin:17-jdk-jammy +# copies build files and Gradle wrapper to the image stage +COPY build.gradle settings.gradle gradlew ./ +COPY gradle gradle + +# downloads dependencies and caches them in a Docker image layer +RUN ./gradlew dependencies --no-daemon || true + +# copies source code to the image stage +COPY src src + +# runs a temporary build container, builds the executable .jar, and saves the generated files as a new image layer +RUN ./gradlew bootJar --no-daemon + +### Runtime stage + +# creates a smaller runtime image stage with only the JRE +FROM eclipse-temurin:17-jre-jammy + +# sets the working directory inside the runtime image WORKDIR /app -COPY --from=build /app/build/libs/*.jar /app/task-scheduler.jar -EXPOSE 8081 -CMD ["java", "-jar", "/app/task-scheduler.jar"] +# copies the built .jar file from the build stage into the runtime image +COPY --from=build /app/build/libs/*.jar ts-task.jar + +# documents the port the application container listens on +EXPOSE 8080 +# defines the command executed when the application container starts +ENTRYPOINT ["java", "-jar", "/app/ts-task.jar"] diff --git a/build.gradle b/build.gradle index 01e8ef4..08bca14 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,13 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.0' + id 'org.springframework.boot' version '4.0.1' id 'io.spring.dependency-management' version '1.1.7' - id "org.sonarqube" version "7.2.3.7755" + id 'org.sonarqube' version '7.2.3.7755' } group = 'com.javanauta.ts' -version = '0.0.1-SNAPSHOT' -description = 'Task Scheduler - Task scheduler module' +version = '1.0.0-SNAPSHOT' +description = 'Task Scheduler - Task module' java { toolchain { @@ -21,25 +21,24 @@ configurations { } } -repositories { - mavenCentral() -} - ext { - set('springCloudVersion', "2025.1.0") + set('springCloudVersion', '2025.1.0') } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webmvc' - implementation 'org.springframework.boot:spring-boot-starter-actuator:4.0.0' + implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'io.jsonwebtoken:jjwt-api:0.13.0' implementation 'org.mapstruct:mapstruct:1.6.3' implementation 'org.mapstruct:mapstruct-processor:1.6.3' implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'org.springframework.boot:spring-boot-starter-amqp' + implementation 'com.github.enzobbom:ts-events:v1.0.0' + implementation 'com.github.enzobbom:ts-api-contract:v1.1.0' compileOnly 'org.projectlombok:lombok' @@ -62,14 +61,18 @@ dependencyManagement { } } +tasks.named('jar') { + enabled = false +} + tasks.named('test') { useJUnitPlatform() } sonarqube { properties { - property "sonar.projectKey", "task-scheduler" - property "sonar.host.url", "http://localhost:9000" - property "sonar.login", "squ_dc76e8eeb6ed2d1dfc066db1af123d8dde47f3f6" + property 'sonar.projectKey', 'task' + property 'sonar.host.url', 'http://localhost:9000' + property 'sonar.login', 'squ_dc76e8eeb6ed2d1dfc066db1af123d8dde47f3f6' } } diff --git a/settings.gradle b/settings.gradle index 67d2973..70f4251 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,17 @@ -rootProject.name = 'task-scheduler' +rootProject.name = 'task' + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + mavenCentral() + + maven { + url = uri("https://jitpack.io") + + content { + includeGroup("com.github.enzobbom") + } + } + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/TaskSchedulerApplication.java b/src/main/java/com/javanauta/ts/taskscheduler/TaskApplication.java similarity index 58% rename from src/main/java/com/javanauta/ts/taskscheduler/TaskSchedulerApplication.java rename to src/main/java/com/javanauta/ts/taskscheduler/TaskApplication.java index 328e085..994f6cb 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/TaskSchedulerApplication.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/TaskApplication.java @@ -1,14 +1,16 @@ package com.javanauta.ts.taskscheduler; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; -import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = MongoAutoConfiguration.class) -@EnableFeignClients -public class TaskSchedulerApplication { +@EnableScheduling +@EnableRabbit +public class TaskApplication { public static void main(String[] args) { - SpringApplication.run(TaskSchedulerApplication.class, args); + SpringApplication.run(TaskApplication.class, args); } } diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/NotificationEventRecoverer.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/NotificationEventRecoverer.java new file mode 100644 index 0000000..43d2b36 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/NotificationEventRecoverer.java @@ -0,0 +1,92 @@ +package com.javanauta.ts.taskscheduler.adapters.in.messaging; + +import com.javanauta.ts.events.notification.NotificationEvent; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import jakarta.validation.ConstraintViolationException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.retry.MessageRecoverer; +import org.springframework.amqp.support.converter.MessageConversionException; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Component +@AllArgsConstructor +@Slf4j +public class NotificationEventRecoverer implements MessageRecoverer { + private final MessageConverter messageConverter; + + @Override + public void recover(Message message, Throwable cause) { + // Pre-listener conversion failure + if (findCause(cause, MessageConversionException.class) != null) { + log.error(""" + Invalid Event received. + Payload: + {} + """, new String(message.getBody(), StandardCharsets.UTF_8), cause); + return; + } + + NotificationEvent event; + try { + event = (NotificationEvent) messageConverter.fromMessage(message); + } catch (MessageConversionException e) { + log.error(""" + Attempt to deserialize Event within the Recoverer failed. + Payload: + {} + """, new String(message.getBody(), StandardCharsets.UTF_8), e); + return; + } + + // Validation errors + ConstraintViolationException constraintViolationException = findCause(cause, ConstraintViolationException.class); + if (constraintViolationException != null) { + List constraintMessages = constraintViolationException + .getConstraintViolations() + .stream() + .map(violation -> violation.getPropertyPath() + " " + violation.getMessage()) + .toList(); + + log.error("Validation of {} for Task {} has failed: {}", + event.getClass().getSimpleName(), + event.taskId(), + String.join(", ", constraintMessages)); + + return; + } + + // Business error + String errorMsg; + ApplicationException appException = findCause(cause, ApplicationException.class); + if (appException != null) { + log.error("Processing of {} for Task {} has failed due to a business error ({}): {}", + event.getClass().getSimpleName(), + event.taskId(), + appException.getCode().getIdentifier(), + appException.getMessage()); + return; + } + + // Any other unhandled exception + log.error("Processing of {} for Task {} has failed due to an internal error", + event.getClass().getSimpleName(), + event.taskId(), + cause); + } + + private T findCause(Throwable throwable, Class type) { + while (throwable != null) { + if (type.isInstance(throwable)) { + return type.cast(throwable); + } + throwable = throwable.getCause(); + } + return null; + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationCompletedListener.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationCompletedListener.java new file mode 100644 index 0000000..ef82262 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationCompletedListener.java @@ -0,0 +1,44 @@ +package com.javanauta.ts.taskscheduler.adapters.in.messaging; + +import com.javanauta.ts.events.notification.NotificationCompletedEvent; +import com.javanauta.ts.events.notification.messaging.Queues; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.validation.NotificationEventValidator; +import com.javanauta.ts.taskscheduler.application.data.NotificationResultDetails; +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import jakarta.validation.ConstraintViolationException; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +@Slf4j +public class RabbitNotificationCompletedListener { + private final TaskService taskService; + private final NotificationEventValidator notificationEventValidator; + + @RabbitListener(queues = Queues.NOTIFICATION_COMPLETED) + public void handleNotificationCompleted(NotificationCompletedEvent event) { + try { + notificationEventValidator.validate(event); + } catch (ConstraintViolationException ex) { + throw new AmqpRejectAndDontRequeueException(ex); + } + + NotificationResultDetails resultDetails = new NotificationResultDetails( + event.taskId(), + NotificationResult.SUCCESS, + null); + + try { + taskService.processTaskNotificationCompletion(resultDetails); + } catch (ApplicationException ex) { + // No retryable exceptions. If any exception is thrown, is probably database related and could be recovered + throw new AmqpRejectAndDontRequeueException(ex); + } + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationFailedListener.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationFailedListener.java new file mode 100644 index 0000000..9d5ada7 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/RabbitNotificationFailedListener.java @@ -0,0 +1,49 @@ +package com.javanauta.ts.taskscheduler.adapters.in.messaging; + +import com.javanauta.ts.events.notification.messaging.Queues; +import com.javanauta.ts.events.notification.NotificationFailedEvent; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.validation.NotificationEventValidator; +import com.javanauta.ts.taskscheduler.application.data.NotificationResultDetails; +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import jakarta.validation.ConstraintViolationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RabbitNotificationFailedListener { + private final TaskService taskService; + private final NotificationEventValidator notificationEventValidator; + + @RabbitListener(queues = Queues.NOTIFICATION_FAILED) + public void handleNotificationFailed(NotificationFailedEvent event) { + try { + notificationEventValidator.validate(event); + } catch (ConstraintViolationException ex) { + throw new AmqpRejectAndDontRequeueException(ex); + } + + NotificationResult result = switch (event.failureType()) { + case PERMANENT -> NotificationResult.PERMANENT_FAILURE; + case TEMPORARY -> NotificationResult.TEMPORARY_FAILURE; + }; + + NotificationResultDetails resultDetails = new NotificationResultDetails( + event.taskId(), + result, + event.error()); + + try { + taskService.processTaskNotificationFailure(resultDetails); + } catch (ApplicationException ex) { + // No retryable exceptions. If any exception is thrown, is probably database related and could be recovered + throw new AmqpRejectAndDontRequeueException(ex); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/config/RabbitInConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/config/RabbitInConfig.java new file mode 100644 index 0000000..b594383 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/config/RabbitInConfig.java @@ -0,0 +1,44 @@ +package com.javanauta.ts.taskscheduler.adapters.in.messaging.config; + +import com.javanauta.ts.taskscheduler.adapters.in.messaging.NotificationEventRecoverer; +import org.aopalliance.aop.Advice; +import org.springframework.amqp.AmqpRejectAndDontRequeueException; +import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.support.converter.MessageConversionException; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitInConfig { + @Bean + public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( + ConnectionFactory connectionFactory, + MessageConverter messageConverter, + Advice retryAdvice) { + + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + + factory.setConnectionFactory(connectionFactory); + factory.setMessageConverter(messageConverter); + + factory.setAdviceChain(retryAdvice); + + return factory; + } + + @Bean + public Advice retryAdvice(NotificationEventRecoverer notificationEventRecoverer) { + return RetryInterceptorBuilder.stateless() + .configureRetryPolicy(builder -> builder + .maxRetries(3) + .excludes( + MessageConversionException.class, + AmqpRejectAndDontRequeueException.class)) + .backOffOptions(60000, 1.0, 60000) + .recoverer(notificationEventRecoverer) + .build(); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/validation/NotificationEventValidator.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/validation/NotificationEventValidator.java new file mode 100644 index 0000000..8ea1579 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/messaging/validation/NotificationEventValidator.java @@ -0,0 +1,24 @@ +package com.javanauta.ts.taskscheduler.adapters.in.messaging.validation; + +import com.javanauta.ts.events.Event; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class NotificationEventValidator { + private final Validator validator; + + public void validate(T event) { + Set> violations = validator.validate(event); + + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/scheduler/TaskNotificationTrigger.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/scheduler/TaskNotificationTrigger.java new file mode 100644 index 0000000..88aebb1 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/scheduler/TaskNotificationTrigger.java @@ -0,0 +1,28 @@ +package com.javanauta.ts.taskscheduler.adapters.in.scheduler; + +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TaskNotificationTrigger { + private final TaskService taskService; + + @Scheduled(cron = "${ts.scheduler.frequency}") + public void findTasksInNextNotificationPeriod() { + long notificationPeriod = 1L; // in hours - currently hard coded but meant to be task setting + + Instant nowTime = Instant.now(); + List tasksList = taskService.findTasksToNotify(nowTime, nowTime.plus(notificationPeriod, ChronoUnit.HOURS)); + tasksList.forEach(taskService::requestTaskNotification); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/AuthenticatedPrincipal.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/AuthenticatedPrincipal.java new file mode 100644 index 0000000..217a1ab --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/AuthenticatedPrincipal.java @@ -0,0 +1,9 @@ +package com.javanauta.ts.taskscheduler.adapters.in.security.authentication; + +import java.util.UUID; + +public record AuthenticatedPrincipal( + UUID id, + String email +) { +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/ForwardedIdentityFilter.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/ForwardedIdentityFilter.java new file mode 100644 index 0000000..3d9e226 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/authentication/ForwardedIdentityFilter.java @@ -0,0 +1,49 @@ +package com.javanauta.ts.taskscheduler.adapters.in.security.authentication; + +import com.javanauta.ts.apicontract.http.HttpHeaders; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.UUID; + +@Component +public class ForwardedIdentityFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String userId = request.getHeader(HttpHeaders.USER_ID); + String email = request.getHeader(HttpHeaders.USER_EMAIL); + + if (userId != null && email != null) { + AuthenticatedPrincipal principal = + new AuthenticatedPrincipal( + UUID.fromString(userId), + email + ); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, + null, + Collections.emptyList() + ); + + SecurityContextHolder + .getContext() + .setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/config/SecurityConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/config/SecurityConfig.java new file mode 100644 index 0000000..14937dd --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/config/SecurityConfig.java @@ -0,0 +1,46 @@ +package com.javanauta.ts.taskscheduler.adapters.in.security.config; + +import com.javanauta.ts.taskscheduler.adapters.in.security.authentication.ForwardedIdentityFilter; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@SecurityScheme(name = SecurityConfig.SECURITY_SCHEME, type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer") +@RequiredArgsConstructor +public class SecurityConfig { + public static final String SECURITY_SCHEME = "bearerAuth"; + private final ForwardedIdentityFilter forwardedIdentityFilter; + private final AuthenticationEntryPoint authenticationEntryPoint; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/actuator/health/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/error").permitAll() + .anyRequest().authenticated() // Requires authentication for all other requests + ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint)) + .addFilterBefore( + forwardedIdentityFilter, + UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/JwtAuthenticationEntryPoint.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..505ed5c --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,44 @@ +package com.javanauta.ts.taskscheduler.adapters.in.security.exception; + +import com.javanauta.ts.apicontract.response.ErrorResponse; +import com.javanauta.ts.taskscheduler.adapters.in.security.exception.enums.SecurityExceptionCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + log.error("Authentication exception", authException); + + int httpStatusCode = HttpStatus.UNAUTHORIZED.value(); + SecurityExceptionCode exceptionCode = SecurityExceptionCode.AUTHENTICATION_ERROR; + + ErrorResponse errorResponse = new ErrorResponse( + httpStatusCode, + exceptionCode.getIdentifier(), + exceptionCode.getDefaultMessage(), + List.of() + ); + + response.setStatus(httpStatusCode); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/enums/SecurityExceptionCode.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/enums/SecurityExceptionCode.java new file mode 100644 index 0000000..161f54e --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/security/exception/enums/SecurityExceptionCode.java @@ -0,0 +1,18 @@ +package com.javanauta.ts.taskscheduler.adapters.in.security.exception.enums; + +import com.javanauta.ts.taskscheduler.shared.exception.ExceptionCode; + +public enum SecurityExceptionCode implements ExceptionCode { + AUTHENTICATION_ERROR ("Authentication failed"); + + private final String defaultMessage; + + SecurityExceptionCode(String defaultMessage) { + this.defaultMessage = defaultMessage; + } + + @Override + public String getDefaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/config/OpenApiConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/config/OpenApiConfig.java new file mode 100644 index 0000000..e35ed99 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/config/OpenApiConfig.java @@ -0,0 +1,22 @@ +package com.javanauta.ts.taskscheduler.adapters.in.web.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI taskOpenAPI() { + return new OpenAPI() + .servers(List.of(new Server().url("/"))) + .info(new Info() + .title("Task Service API") + .version("1.0.0")); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/controller/TaskController.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/controller/TaskController.java new file mode 100644 index 0000000..d11fcef --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/controller/TaskController.java @@ -0,0 +1,144 @@ +package com.javanauta.ts.taskscheduler.adapters.in.web.controller; + +import com.javanauta.ts.apicontract.response.ErrorResponse; +import com.javanauta.ts.apicontract.response.SuccessResponse; +import com.javanauta.ts.taskscheduler.adapters.in.security.config.SecurityConfig; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.CreateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.UpdateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.out.TaskResponseDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.mapper.TaskMapper; +import com.javanauta.ts.taskscheduler.adapters.in.web.path.ApiPaths; +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping(ApiPaths.TASKS_V1) +@SecurityRequirement(name = SecurityConfig.SECURITY_SCHEME) +@Tag(name = "Tasks", description = "Operations for managing the authenticated user's tasks") +@Validated +@RequiredArgsConstructor +public class TaskController { + private final TaskService taskService; + private final TaskMapper taskMapper; + + @PostMapping + @Operation(summary = "Create task", description = "Creates a new task for the authenticated user") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Task successfully created"), + @ApiResponse(responseCode = "401", description = "Authentication failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "Request body validation failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity> createTask( + @RequestBody @Valid CreateTaskRequestDTO createTaskRequestDTO) { + + Task createdTask = taskService.createTask(taskMapper.fromCreateTaskRequestDTO(createTaskRequestDTO)); + + HttpStatus httpCode = HttpStatus.OK; + SuccessResponse successResponse = new SuccessResponse<>( + httpCode.value(), + taskMapper.toTaskDTO(createdTask)); + + return ResponseEntity.status(httpCode).body(successResponse); + } + + @GetMapping + @Operation(summary = "Get user tasks", description = "Returns all tasks belonging to the authenticated user") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Tasks successfully retrieved"), + @ApiResponse(responseCode = "401", description = "Authentication failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity>> getTasks() { + List tasks = taskService.getTasks(); + + HttpStatus httpCode = HttpStatus.OK; + SuccessResponse> successResponse = new SuccessResponse<>( + httpCode.value(), + taskMapper.toTaskDTOList(tasks)); + + return ResponseEntity.status(httpCode).body(successResponse); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete task", description = "Deletes a task owned by the authenticated user") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Task successfully deleted"), + @ApiResponse(responseCode = "401", description = "Authentication failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "User does not own the requested task", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Task not found", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "Request parameter validation failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity deleteTask( + @PathVariable @NotBlank String id) { + + taskService.deleteTask(id); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PatchMapping("/{id}") + @Operation( + summary = "Update task", + description = """ + Updates a task owned by the authenticated user. + + PATCH behavior: + > Missing fields are ignored \n\n + > Fields sent as null are ignored \n\n + > Send an empty string ("") to clear optional String fields ('description') + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Task successfully updated"), + @ApiResponse(responseCode = "401", description = "Authentication failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "User does not own the requested task", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Task not found", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "Request validation failed", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal server error", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + public ResponseEntity> updateTask( + @RequestBody @Valid UpdateTaskRequestDTO taskDTO, + @PathVariable @NotBlank String id) { + + Task task = taskService.updateTask(taskMapper.fromUpdateTaskRequestDTO(taskDTO), id); + + HttpStatus httpCode = HttpStatus.OK; + SuccessResponse successResponse = new SuccessResponse<>( + httpCode.value(), + taskMapper.toTaskDTO(task)); + + return ResponseEntity.status(httpCode).body(successResponse); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/CreateTaskRequestDTO.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/CreateTaskRequestDTO.java similarity index 68% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/CreateTaskRequestDTO.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/CreateTaskRequestDTO.java index 8a37bf3..507aa2f 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/CreateTaskRequestDTO.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/CreateTaskRequestDTO.java @@ -1,6 +1,6 @@ -package com.javanauta.ts.taskscheduler.presentation.dto.in; +package com.javanauta.ts.taskscheduler.adapters.in.web.dto.in; -import com.javanauta.ts.taskscheduler.presentation.validation.ValidZoneId; +import com.javanauta.ts.taskscheduler.adapters.in.web.validation.ValidZoneId; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Builder; @@ -12,7 +12,7 @@ @Jacksonized public record CreateTaskRequestDTO( @NotBlank String name, - @NotNull String description, + String description, @NotNull Instant scheduledDateTime, @NotBlank @ValidZoneId String timeZoneId) { } diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/UpdateTaskRequestDTO.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/UpdateTaskRequestDTO.java similarity index 57% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/UpdateTaskRequestDTO.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/UpdateTaskRequestDTO.java index fea0c4d..ddf649a 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/in/UpdateTaskRequestDTO.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/in/UpdateTaskRequestDTO.java @@ -1,7 +1,7 @@ -package com.javanauta.ts.taskscheduler.presentation.dto.in; +package com.javanauta.ts.taskscheduler.adapters.in.web.dto.in; -import com.javanauta.ts.taskscheduler.presentation.validation.AtLeastOneField; -import com.javanauta.ts.taskscheduler.presentation.validation.ValidZoneId; +import com.javanauta.ts.taskscheduler.adapters.in.web.validation.AtLeastOneField; +import com.javanauta.ts.taskscheduler.adapters.in.web.validation.ValidZoneId; import lombok.Builder; import lombok.extern.jackson.Jacksonized; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/out/TaskResponseDTO.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/out/TaskResponseDTO.java new file mode 100644 index 0000000..d030d08 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/dto/out/TaskResponseDTO.java @@ -0,0 +1,22 @@ +package com.javanauta.ts.taskscheduler.adapters.in.web.dto.out; + +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +@Builder +public record TaskResponseDTO( + String id, + String name, + String description, + Instant creationDateTime, + Instant scheduledDateTime, + UUID userId, + String userEmail, + Instant modificationDateTime, + NotificationStatus notificationStatus, + String timeZoneId +) { +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/GlobalExceptionHandler.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java similarity index 87% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/GlobalExceptionHandler.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java index 4e2ba8d..9f04a4b 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java @@ -1,11 +1,10 @@ -package com.javanauta.ts.taskscheduler.presentation.exception; +package com.javanauta.ts.taskscheduler.adapters.in.web.exception; -import com.javanauta.ts.taskscheduler.application.exception.ServiceValidationException; +import com.javanauta.ts.apicontract.response.ValidationErrorDetail; import com.javanauta.ts.taskscheduler.application.exception.enums.ServiceExceptionCode; import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainExceptionCode; -import com.javanauta.ts.taskscheduler.presentation.exception.enums.PresentationExceptionCode; -import com.javanauta.ts.taskscheduler.presentation.response.ErrorResponse; -import com.javanauta.ts.taskscheduler.presentation.response.ValidationErrorDetail; +import com.javanauta.ts.taskscheduler.adapters.in.web.exception.enums.PresentationExceptionCode; +import com.javanauta.ts.apicontract.response.ErrorResponse; import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; import com.javanauta.ts.taskscheduler.shared.exception.ExceptionCode; import com.javanauta.ts.taskscheduler.shared.exception.enums.ValidationExceptionSourceType; @@ -29,7 +28,6 @@ @ControllerAdvice @Slf4j public class GlobalExceptionHandler { - private static final Map BUSINESS_CODE_HTTP_STATUS_MAP = Map.of( ServiceExceptionCode.TASK_NOT_FOUND, HttpStatus.NOT_FOUND, ServiceExceptionCode.NO_TASK_OWNERSHIP, HttpStatus.FORBIDDEN, @@ -85,7 +83,7 @@ public ResponseEntity handleMethodArgumentNotValidException( allValidationErrors.addAll(fieldErrors); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, exceptionCode.getDefaultMessage(), allValidationErrors); @@ -109,7 +107,7 @@ public ResponseEntity handleConstraintViolationException(Constrai .toList(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, exceptionCode.getDefaultMessage(), constraintViolations); @@ -128,7 +126,7 @@ public ResponseEntity handleTypeMismatchException(MethodArgumentT String message = "Invalid value for parameter: " + ex.getName(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, message, List.of()); @@ -144,7 +142,7 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM String errorIdentifier = exceptionCode.getIdentifier(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, exceptionCode.getDefaultMessage(), List.of()); @@ -161,7 +159,7 @@ public ResponseEntity handleMissingServletRequestParameterExcepti String message = "Missing required parameter: " + ex.getParameterName(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, message, List.of()); @@ -185,7 +183,7 @@ public ResponseEntity handleApplicationException(ApplicationExcep .toList(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), exceptionCode.getIdentifier(), ex.getMessage(), validationErrors); @@ -193,24 +191,6 @@ public ResponseEntity handleApplicationException(ApplicationExcep return ResponseEntity.status(httpCode).body(errorResponse); } - // Service exceptions - - // To be removed as it will be used internally only - @ExceptionHandler(ServiceValidationException.class) - public ResponseEntity handlerServiceValidationException(ServiceValidationException ex) { - ExceptionCode exceptionCode = PresentationExceptionCode.INTERNAL_SERVER_ERROR; - HttpStatus httpCode = PRESENTATION_CODE_HTTP_STATUS_MAP.get(exceptionCode); - String errorIdentifier = exceptionCode.getIdentifier(); - - ErrorResponse errorResponse = new ErrorResponse( - httpCode, - errorIdentifier, - ex.getMessage(), - List.of()); - - return ResponseEntity.status(httpCode).body(errorResponse); - } - // Generic error handling @ExceptionHandler(Exception.class) @@ -222,7 +202,7 @@ public ResponseEntity handleGenericException(Exception ex) { String errorIdentifier = exceptionCode.getIdentifier(); ErrorResponse errorResponse = new ErrorResponse( - httpCode, + httpCode.value(), errorIdentifier, exceptionCode.getDefaultMessage(), List.of()); diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/enums/PresentationExceptionCode.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java similarity index 93% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/enums/PresentationExceptionCode.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java index 42e8daf..6a8a403 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/exception/enums/PresentationExceptionCode.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.presentation.exception.enums; +package com.javanauta.ts.taskscheduler.adapters.in.web.exception.enums; import com.javanauta.ts.taskscheduler.shared.exception.ExceptionCode; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/mapper/TaskMapper.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/mapper/TaskMapper.java similarity index 76% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/mapper/TaskMapper.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/mapper/TaskMapper.java index 49cfed3..62b2f94 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/mapper/TaskMapper.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/mapper/TaskMapper.java @@ -1,10 +1,10 @@ -package com.javanauta.ts.taskscheduler.presentation.mapper; +package com.javanauta.ts.taskscheduler.adapters.in.web.mapper; -import com.javanauta.ts.taskscheduler.domain.data.TaskData; +import com.javanauta.ts.taskscheduler.application.data.TaskData; import com.javanauta.ts.taskscheduler.domain.model.Task; -import com.javanauta.ts.taskscheduler.presentation.dto.out.TaskResponseDTO; -import com.javanauta.ts.taskscheduler.presentation.dto.in.CreateTaskRequestDTO; -import com.javanauta.ts.taskscheduler.presentation.dto.in.UpdateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.out.TaskResponseDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.CreateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.UpdateTaskRequestDTO; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.NullValuePropertyMappingStrategy; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/path/ApiPaths.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/path/ApiPaths.java new file mode 100644 index 0000000..5469ab6 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/path/ApiPaths.java @@ -0,0 +1,9 @@ +package com.javanauta.ts.taskscheduler.adapters.in.web.path; + +import com.javanauta.ts.apicontract.version.ApiVersions; + +public final class ApiPaths { + private ApiPaths() {} + + public static final String TASKS_V1 = ApiVersions.V1 + "/tasks"; +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneField.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneField.java similarity index 89% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneField.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneField.java index d906342..4d3919b 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneField.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneField.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.presentation.validation; +package com.javanauta.ts.taskscheduler.adapters.in.web.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneFieldValidator.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneFieldValidator.java similarity index 92% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneFieldValidator.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneFieldValidator.java index 5125b90..1b62a1b 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/AtLeastOneFieldValidator.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/AtLeastOneFieldValidator.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.presentation.validation; +package com.javanauta.ts.taskscheduler.adapters.in.web.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneId.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneId.java similarity index 89% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneId.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneId.java index 7625a0a..54dd32a 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneId.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneId.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.presentation.validation; +package com.javanauta.ts.taskscheduler.adapters.in.web.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneIdValidator.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneIdValidator.java similarity index 89% rename from src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneIdValidator.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneIdValidator.java index 572f163..e490943 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/validation/ValidZoneIdValidator.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/validation/ValidZoneIdValidator.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.presentation.validation; +package com.javanauta.ts.taskscheduler.adapters.in.web.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/messaging/RabbitNotificationRequestPublisher.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/messaging/RabbitNotificationRequestPublisher.java new file mode 100644 index 0000000..c217891 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/messaging/RabbitNotificationRequestPublisher.java @@ -0,0 +1,36 @@ +package com.javanauta.ts.taskscheduler.adapters.out.messaging; + +import com.javanauta.ts.events.notification.NotificationRequestEvent; +import com.javanauta.ts.events.notification.messaging.Exchanges; +import com.javanauta.ts.events.notification.messaging.RoutingKeys; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.ports.out.messaging.NotificationRequestPublisher; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +@Slf4j +public class RabbitNotificationRequestPublisher implements NotificationRequestPublisher { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void publishNotificationRequest(Task task) { + NotificationRequestEvent event = NotificationRequestEvent.create( + task.getId(), + task.getName(), + task.getDescription(), + task.getScheduledDateTime(), + task.getUserEmail(), + task.getTimeZoneId().toString()); + + rabbitTemplate.convertAndSend( + Exchanges.NOTIFICATION, + RoutingKeys.NOTIFICATION_REQUEST, + event + ); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/MongoTaskRepository.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/MongoTaskRepository.java new file mode 100644 index 0000000..bc9dc4b --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/MongoTaskRepository.java @@ -0,0 +1,17 @@ +package com.javanauta.ts.taskscheduler.adapters.out.persistence; + +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Repository +public interface MongoTaskRepository extends MongoRepository { + List findByNotificationStatusInAndScheduledDateTimeBetween(Collection statuses, Instant initialDateTime, Instant finalDateTime); + List findByUserId(UUID userId); +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/TaskPersisterAdapter.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/TaskPersisterAdapter.java new file mode 100644 index 0000000..70d85d3 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/TaskPersisterAdapter.java @@ -0,0 +1,44 @@ +package com.javanauta.ts.taskscheduler.adapters.out.persistence; + +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import com.javanauta.ts.taskscheduler.ports.out.persistence.TaskPersister; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class TaskPersisterAdapter implements TaskPersister { + private final MongoTaskRepository taskRepository; + + @Override + public Task save(Task task) { + return taskRepository.save(task); + } + + @Override + public void deleteById(String id) { + taskRepository.deleteById(id); + } + + @Override + public Optional findById(String id) { + return taskRepository.findById(id); + } + + @Override + public List findByNotificationStatusInAndScheduledDateTimeBetween(Collection statuses, Instant initialDateTime, Instant finalDateTime) { + return taskRepository.findByNotificationStatusInAndScheduledDateTimeBetween(statuses, initialDateTime, finalDateTime); + } + + @Override + public List findByUserId(UUID userId) { + return taskRepository.findByUserId(userId); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/config/MongoConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/config/MongoConfig.java new file mode 100644 index 0000000..0948773 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/config/MongoConfig.java @@ -0,0 +1,63 @@ +package com.javanauta.ts.taskscheduler.adapters.out.persistence.config; + +import com.javanauta.ts.taskscheduler.adapters.out.persistence.converter.ZoneIdReadConverter; +import com.javanauta.ts.taskscheduler.adapters.out.persistence.converter.ZoneIdWriteConverter; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import lombok.extern.slf4j.Slf4j; +import org.bson.UuidRepresentation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +import java.util.List; + +@Slf4j +@Configuration +public class MongoConfig { + + @Bean + public MongoClient mongoClient( + @Value("${spring.data.mongodb.uri}") String uri, + @Value("${spring.data.mongodb.uuid-representation}") UuidRepresentation uuidRepresentation) { + + MongoClientSettings mongoClientSettings = MongoClientSettings.builder() + .uuidRepresentation(uuidRepresentation) + .applyConnectionString(new ConnectionString(uri)) + .build(); + + return MongoClients.create(mongoClientSettings); + } + + @Bean + public MongoDatabaseFactory mongoDatabaseFactory( + MongoClient mongoClient, + @Value("${spring.data.mongodb.database}") String databaseName) { + + return new SimpleMongoClientDatabaseFactory( + mongoClient, + databaseName + ); + } + + @Bean + public MongoTemplate mongoTemplate(MongoDatabaseFactory factory) { + return new MongoTemplate(factory); + } + + @Bean + public MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions( + List.of( + new ZoneIdReadConverter(), + new ZoneIdWriteConverter() + ) + ); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdReadConverter.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdReadConverter.java similarity index 81% rename from src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdReadConverter.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdReadConverter.java index 8e5849b..03ea92e 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdReadConverter.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdReadConverter.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.infrastructure.persistence.converter; +package com.javanauta.ts.taskscheduler.adapters.out.persistence.converter; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdWriteConverter.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdWriteConverter.java similarity index 81% rename from src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdWriteConverter.java rename to src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdWriteConverter.java index 4a3465c..0d67b7d 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/persistence/converter/ZoneIdWriteConverter.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/persistence/converter/ZoneIdWriteConverter.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.infrastructure.persistence.converter; +package com.javanauta.ts.taskscheduler.adapters.out.persistence.converter; import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.WritingConverter; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/security/context/SecurityContextPrincipalProvider.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/security/context/SecurityContextPrincipalProvider.java new file mode 100644 index 0000000..10f2b52 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/out/security/context/SecurityContextPrincipalProvider.java @@ -0,0 +1,41 @@ +package com.javanauta.ts.taskscheduler.adapters.out.security.context; + +import com.javanauta.ts.taskscheduler.adapters.in.security.authentication.AuthenticatedPrincipal; +import com.javanauta.ts.taskscheduler.ports.out.security.PrincipalProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class SecurityContextPrincipalProvider implements PrincipalProvider { + + @Override + public UUID getId() { + return getAuthenticatedUser().id(); + } + + @Override + public String getEmail() { + return getAuthenticatedUser().email(); + } + + private AuthenticatedPrincipal getAuthenticatedUser() { + Authentication authentication = + SecurityContextHolder + .getContext() + .getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + throw new IllegalStateException("No authenticated user found"); + } + + Object principal = authentication.getPrincipal(); + if (!(principal instanceof AuthenticatedPrincipal user)) { + throw new IllegalStateException("Invalid authenticated principal"); + } + + return user; + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/shared/config/RabbitCommonConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/shared/config/RabbitCommonConfig.java new file mode 100644 index 0000000..28d0bae --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/shared/config/RabbitCommonConfig.java @@ -0,0 +1,15 @@ +package com.javanauta.ts.taskscheduler.adapters.shared.config; + +import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitCommonConfig { + + @Bean + public MessageConverter messageConverter() { + return new JacksonJsonMessageConverter(); + } +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/data/NotificationResultDetails.java b/src/main/java/com/javanauta/ts/taskscheduler/application/data/NotificationResultDetails.java new file mode 100644 index 0000000..f962e6c --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/application/data/NotificationResultDetails.java @@ -0,0 +1,10 @@ +package com.javanauta.ts.taskscheduler.application.data; + +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; + +public record NotificationResultDetails( + String taskId, + NotificationResult notificationResult, + String errorMessage +) { +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/domain/data/TaskData.java b/src/main/java/com/javanauta/ts/taskscheduler/application/data/TaskData.java similarity index 79% rename from src/main/java/com/javanauta/ts/taskscheduler/domain/data/TaskData.java rename to src/main/java/com/javanauta/ts/taskscheduler/application/data/TaskData.java index 9c469c4..d3e9689 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/domain/data/TaskData.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/application/data/TaskData.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.domain.data; +package com.javanauta.ts.taskscheduler.application.data; import lombok.Builder; diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/data/enums/NotificationResult.java b/src/main/java/com/javanauta/ts/taskscheduler/application/data/enums/NotificationResult.java new file mode 100644 index 0000000..f13814d --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/application/data/enums/NotificationResult.java @@ -0,0 +1,7 @@ +package com.javanauta.ts.taskscheduler.application.data.enums; + +public enum NotificationResult { + SUCCESS, + PERMANENT_FAILURE, + TEMPORARY_FAILURE +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ForbiddenException.java b/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ForbiddenException.java deleted file mode 100644 index 61ca59a..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ForbiddenException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.javanauta.ts.taskscheduler.application.exception; - -public class ForbiddenException extends RuntimeException { - public ForbiddenException(String message) { - super(message); - } - public ForbiddenException(String message, Throwable throwable) { - super(message, throwable); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ResourceNotFoundException.java b/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ResourceNotFoundException.java deleted file mode 100644 index 4bf7dfd..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ResourceNotFoundException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.javanauta.ts.taskscheduler.application.exception; - -public class ResourceNotFoundException extends RuntimeException { - public ResourceNotFoundException(String message) { - super(message); - } - public ResourceNotFoundException(String message, Throwable throwable) { super(message, throwable); } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ServiceValidationException.java b/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ServiceValidationException.java deleted file mode 100644 index 69d09a1..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/application/exception/ServiceValidationException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.javanauta.ts.taskscheduler.application.exception; - -public class ServiceValidationException extends RuntimeException { - public ServiceValidationException(String message) { - super(message); - } - public ServiceValidationException(String message, Throwable throwable) { super(message, throwable); } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/ports/CurrentUserProvider.java b/src/main/java/com/javanauta/ts/taskscheduler/application/ports/CurrentUserProvider.java deleted file mode 100644 index 8755216..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/application/ports/CurrentUserProvider.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.javanauta.ts.taskscheduler.application.ports; - -public interface CurrentUserProvider { - String getEmail(); -} \ No newline at end of file diff --git a/src/main/java/com/javanauta/ts/taskscheduler/application/service/TaskService.java b/src/main/java/com/javanauta/ts/taskscheduler/application/service/TaskService.java index 9b62067..5265e00 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/application/service/TaskService.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/application/service/TaskService.java @@ -1,17 +1,18 @@ package com.javanauta.ts.taskscheduler.application.service; -import com.javanauta.ts.taskscheduler.application.exception.ServiceValidationException; +import com.javanauta.ts.taskscheduler.application.data.NotificationResultDetails; +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; import com.javanauta.ts.taskscheduler.application.exception.enums.ServiceExceptionCode; -import com.javanauta.ts.taskscheduler.application.ports.CurrentUserProvider; -import com.javanauta.ts.taskscheduler.domain.data.TaskData; +import com.javanauta.ts.taskscheduler.application.data.TaskData; import com.javanauta.ts.taskscheduler.domain.model.Task; import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; -import com.javanauta.ts.taskscheduler.infrastructure.repository.TaskRepository; +import com.javanauta.ts.taskscheduler.ports.out.messaging.NotificationRequestPublisher; +import com.javanauta.ts.taskscheduler.ports.out.persistence.TaskPersister; +import com.javanauta.ts.taskscheduler.ports.out.security.PrincipalProvider; import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; @@ -20,75 +21,107 @@ @RequiredArgsConstructor @Slf4j public class TaskService { + private final TaskPersister taskPersister; + private final PrincipalProvider principalProvider; + private final NotificationRequestPublisher notificationRequestPublisher; - private final TaskRepository taskRepository; - private final CurrentUserProvider currentUserProvider; + // Controller public Task createTask(TaskData taskData) { - String userEmail = currentUserProvider.getEmail(); - Task task = Task.create(taskData, userEmail); + Task task = Task.create( + taskData, + principalProvider.getId(), + principalProvider.getEmail()); - Task savedTask = taskRepository.save(task); + Task savedTask = taskPersister.save(task); log.info("Task {} created", savedTask.getId()); return savedTask; } - // End point to use internally. Will be refactored to be authenticated using M2M - public List findTasksByTimePeriod(Instant initialDateTime, Instant finalDateTime) { - validateTimePeriod(initialDateTime, finalDateTime); - return taskRepository.findByScheduledDateTimeBetween(initialDateTime, finalDateTime); - } - - public List findTasksByUserEmail() { - return taskRepository.findByUserEmail(currentUserProvider.getEmail()); + public List getTasks() { + return taskPersister.findByUserId(principalProvider.getId()); } public void deleteTask(String id) { Task task = getTaskOrThrow(id); validateTaskOwnership(task); - taskRepository.deleteById(id); + taskPersister.deleteById(id); log.info("Task {} deleted", id); } - @Transactional - public Task updateTaskStatus(NotificationStatus notificationStatus, String id) { - Task task = getTaskOrThrow(id); - validateTaskOwnership(task); - task.updateStatus(notificationStatus); - - log.info("Status of task {} updated", id); - return task; - } - - @Transactional public Task updateTask(TaskData taskData, String id) { Task task = getTaskOrThrow(id); validateTaskOwnership(task); task.update(taskData); + Task updatedTask = taskPersister.save(task); log.info("Task {} updated", id); - return task; + return updatedTask; + } + + // Scheduler + + public List findTasksToNotify(Instant initialDateTime, Instant finalDateTime) { + return taskPersister.findByNotificationStatusInAndScheduledDateTimeBetween( + NotificationStatus.notifiableStatuses(), + initialDateTime, + finalDateTime); + } + + public void requestTaskNotification(Task task) { + if (!task.canBeNotified()) { return; } + + notificationRequestPublisher.publishNotificationRequest(task); + updateTaskStatus(task, NotificationStatus.DISPATCHED); + + log.info("Notification request for Task '{}' was successfully dispatched", task.getId()); + } + + // Messaging Broker + + public void processTaskNotificationCompletion(NotificationResultDetails resultDetails) { + String taskId = resultDetails.taskId(); + Task task = getTaskOrThrow(taskId); + updateTaskStatus(task, NotificationStatus.NOTIFIED); + + log.info("Notification for Task '{}' was successfully completed", taskId); + } + + public void processTaskNotificationFailure(NotificationResultDetails resultDetails) { + NotificationResult notificationResult = resultDetails.notificationResult(); + String taskId = resultDetails.taskId(); + + log.info("Notification for Task '{}' failed ({})", taskId, notificationResult.name().toLowerCase()); + Task task = getTaskOrThrow(taskId); + + NotificationStatus newStatus = switch (notificationResult) { + case TEMPORARY_FAILURE -> NotificationStatus.PENDING_RETRY; + case PERMANENT_FAILURE -> NotificationStatus.FAILED; + default -> throw new IllegalArgumentException("Only 'FAILURE' results are expected here"); + }; + + updateTaskStatus(task, newStatus); } // internal helper/validation methods private Task getTaskOrThrow(String id) { - return taskRepository.findById(id).orElseThrow(() + return taskPersister.findById(id).orElseThrow(() -> new ApplicationException(ServiceExceptionCode.TASK_NOT_FOUND)); } private void validateTaskOwnership(Task task) { - if (!task.getUserEmail().equals(currentUserProvider.getEmail())) { + if (!task.getUserId().equals(principalProvider.getId())) { throw new ApplicationException(ServiceExceptionCode.NO_TASK_OWNERSHIP); } } - // To be refactored and used internally only!!! - private void validateTimePeriod(Instant initialDateTime, Instant finalDateTime) { - if (!initialDateTime.isBefore(finalDateTime)) { - throw new ServiceValidationException("Initial date time must be before final date time"); - } + private void updateTaskStatus(Task task, NotificationStatus status) { + task.updateStatus(status); + taskPersister.save(task); + + log.info("Status of task {} updated to {}", task.getId(), status); } } diff --git a/src/main/java/com/javanauta/ts/taskscheduler/domain/exception/BusinessValidationException.java b/src/main/java/com/javanauta/ts/taskscheduler/domain/exception/BusinessValidationException.java deleted file mode 100644 index 15bbbc9..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/domain/exception/BusinessValidationException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.javanauta.ts.taskscheduler.domain.exception; - -public class BusinessValidationException extends RuntimeException { - public BusinessValidationException(String message) { - super(message); - } - public BusinessValidationException(String message, Throwable throwable) { - super(message, throwable); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/domain/model/Task.java b/src/main/java/com/javanauta/ts/taskscheduler/domain/model/Task.java index 3cb9f89..93e4465 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/domain/model/Task.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/domain/model/Task.java @@ -1,6 +1,6 @@ package com.javanauta.ts.taskscheduler.domain.model; -import com.javanauta.ts.taskscheduler.domain.data.TaskData; +import com.javanauta.ts.taskscheduler.application.data.TaskData; import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainExceptionCode; import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainValidationExceptionCode; import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; @@ -8,26 +8,34 @@ import com.javanauta.ts.taskscheduler.shared.exception.ValidationExceptionDetail; import lombok.*; import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import java.time.Instant; import java.time.ZoneId; import java.util.List; +import java.util.UUID; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder -@Document("task") +@Document("tasks") +@CompoundIndex( + name = "notification_schedule_idx", + def = "{'notificationStatus': 1, 'scheduledDateTime': 1}" +) public class Task { - @Id private String id; private String name; private String description; private Instant creationDateTime; private Instant scheduledDateTime; + @Indexed + private UUID userId; private String userEmail; private Instant modificationDateTime; private NotificationStatus notificationStatus; @@ -35,10 +43,11 @@ public class Task { private final static String SCHEDULED_DATETIME_FIELD_NAME = "scheduledDateTime"; - private Task(String name, String description, Instant scheduledDateTime, String userEmail, ZoneId timeZoneId) { + private Task(String name, String description, Instant scheduledDateTime, UUID userId, String userEmail, ZoneId timeZoneId) { this.name = name; this.description = description; this.scheduledDateTime = scheduledDateTime; + this.userId = userId; this.userEmail = userEmail; this.timeZoneId = timeZoneId; @@ -48,11 +57,12 @@ private Task(String name, String description, Instant scheduledDateTime, String validateScheduledDate(); } - public static Task create(TaskData taskData, String userEmail) { + public static Task create(TaskData taskData, UUID userId, String userEmail) { return new Task( taskData.name(), taskData.description(), taskData.scheduledDateTime(), + userId, userEmail, taskData.timeZoneId()); } @@ -65,7 +75,7 @@ public void updateStatus(NotificationStatus newStatus) { public void update(TaskData taskData) { if (taskData.name() != null) { name = taskData.name(); } - if (taskData.description() != null) { description = taskData.description(); } + if (taskData.description() != null) { description = taskData.description().isBlank() ? null : taskData.description(); } if (taskData.scheduledDateTime() != null) { scheduledDateTime = taskData.scheduledDateTime(); validateScheduledDate(); @@ -74,6 +84,10 @@ public void update(TaskData taskData) { modificationDateTime = Instant.now(); } + public boolean canBeNotified() { + return NotificationStatus.notifiableStatuses().contains(notificationStatus); + } + private void validateScheduledDate() { if (scheduledDateTime.isBefore(Instant.now())) { diff --git a/src/main/java/com/javanauta/ts/taskscheduler/domain/model/enums/NotificationStatus.java b/src/main/java/com/javanauta/ts/taskscheduler/domain/model/enums/NotificationStatus.java index 508c00e..880ca1f 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/domain/model/enums/NotificationStatus.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/domain/model/enums/NotificationStatus.java @@ -1,7 +1,15 @@ package com.javanauta.ts.taskscheduler.domain.model.enums; +import java.util.Set; + public enum NotificationStatus { PENDING, + DISPATCHED, NOTIFIED, - CANCELED + PENDING_RETRY, + FAILED; + + public static Set notifiableStatuses() { + return Set.of(PENDING, PENDING_RETRY); + } } diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/UserClient.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/UserClient.java deleted file mode 100644 index e2dba27..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/UserClient.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.client; - -import com.javanauta.ts.taskscheduler.infrastructure.client.dto.ExternalUserDTO; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestParam; - -@FeignClient(name = "user", url = "${ts.user.service.uri}") -public interface UserClient { - - @GetMapping - ExternalUserDTO getUserByEmail(@RequestParam("email") String email, @RequestHeader("Authorization") String token); -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/dto/ExternalUserDTO.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/dto/ExternalUserDTO.java deleted file mode 100644 index 1cbc7d2..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/client/dto/ExternalUserDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.client.dto; - -import lombok.*; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class ExternalUserDTO { - - private String email; - private String password; -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/config/MongoConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/config/MongoConfig.java deleted file mode 100644 index c8730a1..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/config/MongoConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.config; - -import com.javanauta.ts.taskscheduler.infrastructure.persistence.converter.ZoneIdReadConverter; -import com.javanauta.ts.taskscheduler.infrastructure.persistence.converter.ZoneIdWriteConverter; -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoClients; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.mongodb.MongoDatabaseFactory; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; -import org.springframework.data.mongodb.core.convert.MongoCustomConversions; - -import java.util.List; - -@Slf4j -@Configuration -public class MongoConfig { - - @Bean - public MongoClient mongoClient(@Value("${spring.data.mongodb.uri}") String uri) { - log.info("Using MongoDB URI: {}", uri); - return MongoClients.create(uri); - } - - @Bean - public MongoDatabaseFactory mongoDatabaseFactory(@Value("${spring.data.mongodb.uri}") String uri) { - return new SimpleMongoClientDatabaseFactory(uri); - } - - @Bean - public MongoTemplate mongoTemplate(MongoDatabaseFactory factory) { - return new MongoTemplate(factory); - } - - @Bean - public MongoCustomConversions mongoCustomConversions() { - return new MongoCustomConversions( - List.of( - new ZoneIdReadConverter(), - new ZoneIdWriteConverter() - ) - ); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/repository/TaskRepository.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/repository/TaskRepository.java deleted file mode 100644 index 06ed8d7..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/repository/TaskRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.repository; - -import com.javanauta.ts.taskscheduler.domain.model.Task; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.time.Instant; -import java.util.List; - -@Repository -public interface TaskRepository extends MongoRepository { - List findByScheduledDateTimeBetween(Instant initialDateTime, Instant finalDateTime); - List findByUserEmail(String userEmail); -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtAuthenticationEntryPoint.java deleted file mode 100644 index 5e21cc4..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtAuthenticationEntryPoint.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Component -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("text/plain"); - response.getWriter().write(authException.getMessage()); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtRequestFilter.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtRequestFilter.java deleted file mode 100644 index 154d6fa..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtRequestFilter.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import feign.FeignException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.authentication.AuthenticationServiceException; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -// Defines the JwtRequestFilter class, which extends OncePerRequestFilter -@Component -public class JwtRequestFilter extends OncePerRequestFilter { - - // Defines properties to store instances of JwtUtil and UserDetailsService - private final JwtUtil jwtUtil; - private final UserDetailsServiceImpl userDetailsService; - - // Constructor that initializes the properties with provided instances - public JwtRequestFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) { - this.jwtUtil = jwtUtil; - this.userDetailsService = userDetailsService; - } - - // Method called once per request to process the filter - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { - - // Gets the value of the "Authorization" header from the request - final String authorizationHeader = request.getHeader("Authorization"); - - // Checks whether the header exists and starts with "Bearer " - if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { - // Extracts the JWT token from the header - final String token = authorizationHeader.substring(7); - String username; - - try { - // Extracts the username from the JWT token - username = jwtUtil.extractUsername(token); - } catch (Exception e) { - throw new AuthenticationServiceException("Invalid JWT token"); - } - - // If the username is not null and the user is not yet authenticated - if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { - // Validate the token - if (!jwtUtil.validateToken(token, username)) { - throw new AuthenticationServiceException("JWT token validation failed"); - } - - UserDetails userDetails; - try { - userDetails = userDetailsService.loadUserDetails(username, authorizationHeader); - } catch (FeignException e) { - throw new AuthenticationServiceException("Failed to load user info from User service", e); - } - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } - - // Continues the filter chain, allowing the request to proceed - chain.doFilter(request, response); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtUtil.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtUtil.java deleted file mode 100644 index 6f57f5b..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/JwtUtil.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Service -public class JwtUtil { - - private JwtParser jwtParser; - - // Secret key used to sign and verify JWT tokens - @Value("${ts.jwt.secret}") - private String secretKey; - - @PostConstruct - public void init() { - jwtParser = Jwts.parser() - .verifyWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8))) - .build(); - } - - // Extracts claims from the JWT token - public Claims extractClaims(String token) { - return jwtParser - .parseSignedClaims(token) // Parses the JWT token and gets claims - .getPayload(); // Returns claims body - } - - // Extracts username from the JWT token - public String extractUsername(String token) { - // Gets the subject (username) from the claims - return extractClaims(token).getSubject(); - } - - // Checks whether the JWT token is expired - public boolean isTokenExpired(String token) { - // Compares token expiration date with current date - return extractClaims(token).getExpiration().before(new Date()); - } - - // Validates the JWT token by checking username match and expiration - public boolean validateToken(String token, String username) { - // Extracts username from the token - final String extractedUsername = extractUsername(token); - // Checks if username matches and token is not expired - return (extractedUsername.equals(username) && !isTokenExpired(token)); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SecurityConfig.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SecurityConfig.java deleted file mode 100644 index cbf072a..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SecurityConfig.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - // JwtUtil and UserDetailsService instances injected by Spring - private final JwtRequestFilter jwtRequestFilter; - private final AuthenticationEntryPoint jwtAuthenticationEntryPoint; - - // Constructor for dependency injection of JwtUtil and UserDetailsService - @Autowired - public SecurityConfig(JwtRequestFilter jwtRequestFilter, AuthenticationEntryPoint jwtAuthenticationEntryPoint) { - this.jwtRequestFilter = jwtRequestFilter; - this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; - - } - - // Security filter configuration - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) { - // Creates a JwtRequestFilter instance using JwtUtil and UserDetailsService - - http - .csrf(AbstractHttpConfigurer::disable) // Disables CSRF protection for REST APIs (not needed in stateless APIs) - - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // Sets session policy to stateless - ) - - .exceptionHandling(ex -> ex - .authenticationEntryPoint(jwtAuthenticationEntryPoint)) - - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/actuator/health").permitAll() - .anyRequest().authenticated() // Requires authentication for all other requests - ) - - .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); // Adds JWT filter before default authentication filter - - // Returns the built security configuration - return http.build(); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SpringSecurityCurrentUserProvider.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SpringSecurityCurrentUserProvider.java deleted file mode 100644 index 79e1eb2..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/SpringSecurityCurrentUserProvider.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import com.javanauta.ts.taskscheduler.application.ports.CurrentUserProvider; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; - -@Component -public class SpringSecurityCurrentUserProvider implements CurrentUserProvider { - - @Override - public String getEmail() { - Authentication authentication = SecurityContextHolder - .getContext() - .getAuthentication(); - - if (authentication == null || - !authentication.isAuthenticated() || - "anonymousUser".equals(authentication.getPrincipal())) { - - throw new IllegalStateException("No authenticated user found"); - } - - return authentication.getName(); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/UserDetailsServiceImpl.java b/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/UserDetailsServiceImpl.java deleted file mode 100644 index 9faccfc..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/infrastructure/security/UserDetailsServiceImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.javanauta.ts.taskscheduler.infrastructure.security; - -import com.javanauta.ts.taskscheduler.infrastructure.client.dto.ExternalUserDTO; -import com.javanauta.ts.taskscheduler.infrastructure.client.UserClient; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Service; - -@Service -public class UserDetailsServiceImpl { - - private final UserClient userClient; - - public UserDetailsServiceImpl(UserClient userClient) { - this.userClient = userClient; - } - - public UserDetails loadUserDetails(String email, String token) { - ExternalUserDTO externalUserDTO = userClient.getUserByEmail(email, token); - - return org.springframework.security.core.userdetails.User - .withUsername(externalUserDTO.getEmail()) // Sets the username as the email - .password(externalUserDTO.getPassword()) // Sets the user's password - .build(); // Builds the UserDetails object - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/ports/out/messaging/NotificationRequestPublisher.java b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/messaging/NotificationRequestPublisher.java new file mode 100644 index 0000000..1d4b71a --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/messaging/NotificationRequestPublisher.java @@ -0,0 +1,7 @@ +package com.javanauta.ts.taskscheduler.ports.out.messaging; + +import com.javanauta.ts.taskscheduler.domain.model.Task; + +public interface NotificationRequestPublisher { + void publishNotificationRequest(Task task); +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/ports/out/persistence/TaskPersister.java b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/persistence/TaskPersister.java new file mode 100644 index 0000000..fa6fa00 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/persistence/TaskPersister.java @@ -0,0 +1,18 @@ +package com.javanauta.ts.taskscheduler.ports.out.persistence; + +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TaskPersister { + Task save(Task task); + void deleteById(String id); + Optional findById(String id); + List findByNotificationStatusInAndScheduledDateTimeBetween(Collection statuses, Instant initialDateTime, Instant finalDateTime); + List findByUserId(UUID userId); +} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/ports/out/security/PrincipalProvider.java b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/security/PrincipalProvider.java new file mode 100644 index 0000000..c203c65 --- /dev/null +++ b/src/main/java/com/javanauta/ts/taskscheduler/ports/out/security/PrincipalProvider.java @@ -0,0 +1,8 @@ +package com.javanauta.ts.taskscheduler.ports.out.security; + +import java.util.UUID; + +public interface PrincipalProvider { + UUID getId(); + String getEmail(); +} \ No newline at end of file diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/controller/TaskController.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/controller/TaskController.java deleted file mode 100644 index ae70515..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/controller/TaskController.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.controller; - -import com.javanauta.ts.taskscheduler.application.service.TaskService; -import com.javanauta.ts.taskscheduler.domain.model.Task; -import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; -import com.javanauta.ts.taskscheduler.presentation.dto.in.CreateTaskRequestDTO; -import com.javanauta.ts.taskscheduler.presentation.dto.in.UpdateTaskRequestDTO; -import com.javanauta.ts.taskscheduler.presentation.mapper.TaskMapper; -import com.javanauta.ts.taskscheduler.presentation.response.SuccessResponse; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.RequiredArgsConstructor; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.time.Instant; -import java.util.List; - -@RestController -@RequestMapping("/task") -@RequiredArgsConstructor -@Validated -public class TaskController { - - private final TaskService taskService; - private final TaskMapper taskMapper; - - @PostMapping - public ResponseEntity createTask( - @RequestBody @Valid CreateTaskRequestDTO createTaskRequestDTO) { - - Task createdTask = taskService.createTask(taskMapper.fromCreateTaskRequestDTO(createTaskRequestDTO)); - - HttpStatus httpCode = HttpStatus.OK; - SuccessResponse successResponse = new SuccessResponse( - httpCode, - taskMapper.toTaskDTO(createdTask)); - - return ResponseEntity.status(httpCode).body(successResponse); - } - - // To be removed (and used internally only) - @GetMapping("/events") - public ResponseEntity findTasksByPeriod( - @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant initialDateTime, - @RequestParam @NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant finalDateTime) { - - List tasks = taskService.findTasksByTimePeriod(initialDateTime, finalDateTime); - - HttpStatus httpCode = HttpStatus.OK; - SuccessResponse successResponse = new SuccessResponse( - httpCode, - taskMapper.toTaskDTOList(tasks)); - - return ResponseEntity.status(httpCode).body(successResponse); - } - - @GetMapping - public ResponseEntity findTasksByUserEmail() { - - List tasks = taskService.findTasksByUserEmail(); - - HttpStatus httpCode = HttpStatus.OK; - SuccessResponse successResponse = new SuccessResponse( - httpCode, - taskMapper.toTaskDTOList(tasks)); - - return ResponseEntity.status(httpCode).body(successResponse); - } - - @DeleteMapping - public ResponseEntity deleteTask( - @RequestParam("id") @NotBlank String id) { - - taskService.deleteTask(id); - return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); - } - - // To be removed (will be done internally once proper asynch communication with Notifier ms is implemented) - @PatchMapping("/status") - public ResponseEntity updateTaskStatus( - @RequestParam("status") @NotNull NotificationStatus notificationStatus, - @RequestParam("id") @NotBlank String id) { - - Task task = taskService.updateTaskStatus(notificationStatus, id); - - HttpStatus httpCode = HttpStatus.OK; - SuccessResponse successResponse = new SuccessResponse( - httpCode, - taskMapper.toTaskDTO(task)); - - return ResponseEntity.status(httpCode).body(successResponse); - } - - @PatchMapping - public ResponseEntity updateTask( - @RequestBody @Valid UpdateTaskRequestDTO taskDTO, - @RequestParam("id") @NotBlank String id) { - - Task task = taskService.updateTask(taskMapper.fromUpdateTaskRequestDTO(taskDTO), id); - - HttpStatus httpCode = HttpStatus.OK; - SuccessResponse successResponse = new SuccessResponse( - httpCode, - taskMapper.toTaskDTO(task)); - - return ResponseEntity.status(httpCode).body(successResponse); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/out/TaskResponseDTO.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/out/TaskResponseDTO.java deleted file mode 100644 index 661574e..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/dto/out/TaskResponseDTO.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.dto.out; - -import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; -import lombok.*; - -import java.time.Instant; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class TaskResponseDTO { - - private String id; - private String name; - private String description; - private Instant creationDateTime; - private Instant scheduledDateTime; - private String userEmail; - private Instant modificationDateTime; - private NotificationStatus notificationStatus; - private String timeZoneId; -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ErrorResponse.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ErrorResponse.java deleted file mode 100644 index 33f455b..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ErrorResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.response; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.javanauta.ts.taskscheduler.presentation.response.enums.ResponseStatus; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -import java.util.List; - -@Getter -@JsonPropertyOrder({"status", "code", "errorCode", "message", "validationErrors"}) -public class ErrorResponse extends Response { - - private final String errorCode; - private final String message; - private final List validationErrors; - - public ErrorResponse(HttpStatus code, String errorCode, String message, List validationErrorDetails) { - super(ResponseStatus.ERROR, code); - this.errorCode = errorCode; - this.message = message; - this.validationErrors = validationErrorDetails; - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/Response.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/Response.java deleted file mode 100644 index 3c40bb6..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/Response.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.response; - -import com.javanauta.ts.taskscheduler.presentation.response.enums.ResponseStatus; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public class Response { - protected final ResponseStatus status; - protected final int code; - - public Response(ResponseStatus status, HttpStatus code) { - this.status = status; - this.code = code.value(); - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/SuccessResponse.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/SuccessResponse.java deleted file mode 100644 index aa6a343..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/SuccessResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.response; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.javanauta.ts.taskscheduler.presentation.response.enums.ResponseStatus; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@JsonPropertyOrder({"status", "code", "data"}) -public class SuccessResponse extends Response { - - private final T data; - - public SuccessResponse(HttpStatus code, T data) { - super(ResponseStatus.SUCCESS, code); - this.data = data; - } - - public SuccessResponse(HttpStatus code) { - super(ResponseStatus.SUCCESS, code); - this.data = null; - } -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ValidationErrorDetail.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ValidationErrorDetail.java deleted file mode 100644 index f2182b5..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/ValidationErrorDetail.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.response; - -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - -@JsonPropertyOrder({"sourceType", "source", "message"}) -public record ValidationErrorDetail( - String sourceType, - String source, - String message -) { -} \ No newline at end of file diff --git a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/enums/ResponseStatus.java b/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/enums/ResponseStatus.java deleted file mode 100644 index 1ef456c..0000000 --- a/src/main/java/com/javanauta/ts/taskscheduler/presentation/response/enums/ResponseStatus.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.javanauta.ts.taskscheduler.presentation.response.enums; - -public enum ResponseStatus { - SUCCESS, - ERROR -} diff --git a/src/main/java/com/javanauta/ts/taskscheduler/shared/exception/ApplicationException.java b/src/main/java/com/javanauta/ts/taskscheduler/shared/exception/ApplicationException.java index 3bca8b1..effb6fb 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/shared/exception/ApplicationException.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/shared/exception/ApplicationException.java @@ -6,7 +6,6 @@ @Getter public class ApplicationException extends RuntimeException { - private final ExceptionCode code; private final String message; private final List validationExceptionDetails; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2d1b273..77cbafd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,17 +1,20 @@ -spring.application.name=task-scheduler -spring.application.description=Task Scheduler - Task scheduler module +spring.application.name=task +spring.application.description=Task Scheduler - Task module +server.forward-headers-strategy = framework server.port=8081 -spring.data.mongodb.database=ts_task_scheduler +ts.scheduler.frequency=${TS_TASK_NOTIFICATION_TRIGGER_FREQUENCY:0 */2 * * * *} management.endpoints.web.exposure.include=health management.endpoint.health.probes.enabled=true -# If environment variables are not defined, localhost defaults will be used (currently, these env vars are only set by -# the System repo's docker compose to run the application in Docker) -spring.data.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/ts_task_scheduler} -ts.user.service.uri=${TS_USER_SERVICE_URI:http://localhost:8080/user} +spring.data.mongodb.uri=mongodb://${TS_MONGO_USER:dev_user}:${TS_MONGO_PASSWORD:devpass}@${TS_MONGO_HOST:localhost}:${TS_MONGO_PORT:27017}?authSource=admin +spring.data.mongodb.database=${TS_MONGO_DB:ts_task} +spring.data.mongodb.uuid-representation=standard -# Secret key used to sign and verify JWT tokens (injected via environment variables) -ts.jwt.secret=${TS_JWT_SECRET} +spring.rabbitmq.host=${TS_RABBITMQ_HOST:localhost} +spring.rabbitmq.port=${TS_RABBITMQ_PORT:5672} +spring.rabbitmq.username=${TS_RABBITMQ_TASK_USER:guest} +spring.rabbitmq.password=${TS_RABBITMQ_TASK_PASSWORD:guest} +spring.rabbitmq.virtual-host=${TS_RABBITMQ_VHOST:/}