diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..db4bd81d93041cddd23e9972be895a4c4317e048 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM openjdk:21-jdk-slim + +WORKDIR /app + +# Install required packages +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Copy application files +COPY . . + +# Build application (if build.gradle.kts exists) +RUN if [ -f "build.gradle.kts" ]; then \ + ./gradlew build -x test; \ + fi + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run application +CMD ["java", "-jar", "build/libs/da-reporting.jar"] diff --git a/README.md b/README.md index f6b38693d8776d95a771350525b4fe55bd5c8c20..93107ec6e5a764dee396694c7d1f278cd6326a1f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,38 @@ --- -title: Da Reporting Dev -emoji: 🐠 -colorFrom: pink -colorTo: red +title: da-reporting (dev) +emoji: 🔧 +colorFrom: blue +colorTo: green sdk: docker -pinned: false +app_port: 8080 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# da-reporting - dev Environment + +This is the da-reporting microservice deployed in the dev environment. + +## Features + +- RESTful API endpoints +- Health monitoring via Actuator +- JWT authentication integration +- PostgreSQL database connectivity + +## API Documentation + +Once deployed, API documentation will be available at: +- Swagger UI: https://huggingface.co/spaces/dalabsai/da-reporting-dev/swagger-ui.html +- Health Check: https://huggingface.co/spaces/dalabsai/da-reporting-dev/actuator/health + +## Environment + +- **Environment**: dev +- **Port**: 8080 +- **Java Version**: 21 +- **Framework**: Spring Boot + +## Deployment + +This service is automatically deployed via the DALab CI/CD pipeline. + +Last updated: 2025-06-16 23:39:54 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000000000000000000000000000000000..a169729789c6977a7c6a5849c0c13e8f63b519a3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,23 @@ +// da-reporting inherits common configuration from parent build.gradle.kts +// This build file adds reporting-specific dependencies + +dependencies { + // DA-Protos common entities and utilities + implementation(project(":da-protos")) + + // Email and notification dependencies + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("com.slack.api:slack-api-client:1.38.0") + + // Cross-service communication for dashboard aggregation + implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.1") + implementation("org.springframework.boot:spring-boot-starter-cache") + + // OpenAPI documentation for dashboard endpoints + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0") +} + +// Configure main application class +configure { + mainClass.set("com.dalab.reporting.DaReportingApplication") +} \ No newline at end of file diff --git a/src/main/docker/Dockerfile b/src/main/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3713a4e7dd69a93668ce1271aa82f71652e8ac11 --- /dev/null +++ b/src/main/docker/Dockerfile @@ -0,0 +1,23 @@ +# Ultra-lean container using Google Distroless +# Expected final size: ~120-180MB (minimal base + JRE + JAR only) + +FROM gcr.io/distroless/java21-debian12:nonroot + +# Set working directory +WORKDIR /app + +# Copy JAR file +COPY build/libs/da-reporting.jar app.jar + +# Expose standard Spring Boot port +EXPOSE 8080 + +# Run application (distroless has no shell, so use exec form) +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+UseG1GC", \ + "-XX:+UseStringDeduplication", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-Dspring.backgroundpreinitializer.ignore=true", \ + "-jar", "app.jar"] diff --git a/src/main/docker/Dockerfile.alpine-jlink b/src/main/docker/Dockerfile.alpine-jlink new file mode 100644 index 0000000000000000000000000000000000000000..ffb0b6cf87260a96fb83ac7a111d2fc905fc3e3b --- /dev/null +++ b/src/main/docker/Dockerfile.alpine-jlink @@ -0,0 +1,43 @@ +# Ultra-minimal Alpine + Custom JRE +# Expected size: ~120-160MB + +# Stage 1: Create custom JRE with only needed modules +FROM eclipse-temurin:21-jdk-alpine as jre-builder +WORKDIR /app + +# Analyze JAR to find required modules +COPY build/libs/*.jar app.jar +RUN jdeps --ignore-missing-deps --print-module-deps app.jar > modules.txt + +# Create minimal JRE with only required modules +RUN jlink \ + --add-modules $(cat modules.txt),java.logging,java.xml,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument \ + --strip-debug \ + --no-man-pages \ + --no-header-files \ + --compress=2 \ + --output /custom-jre + +# Stage 2: Production image +FROM alpine:3.19 +RUN apk add --no-cache tzdata && \ + addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Copy custom JRE +COPY --from=jre-builder /custom-jre /opt/java +ENV JAVA_HOME=/opt/java +ENV PATH="$JAVA_HOME/bin:$PATH" + +WORKDIR /app +COPY build/libs/*.jar app.jar +RUN chown appuser:appgroup app.jar + +USER appuser +EXPOSE 8080 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=70.0", \ + "-XX:+UseG1GC", \ + "-jar", "app.jar"] diff --git a/src/main/docker/Dockerfile.layered b/src/main/docker/Dockerfile.layered new file mode 100644 index 0000000000000000000000000000000000000000..59231523ddffaa9aa5ce1f62b1df71600bae6d7f --- /dev/null +++ b/src/main/docker/Dockerfile.layered @@ -0,0 +1,34 @@ +# Ultra-optimized layered build using Distroless +# Expected size: ~180-220MB with better caching + +FROM gcr.io/distroless/java21-debian12:nonroot as base + +# Stage 1: Extract JAR layers for optimal caching +FROM eclipse-temurin:21-jdk-alpine as extractor +WORKDIR /app +COPY build/libs/*.jar app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +# Stage 2: Production image with extracted layers +FROM base +WORKDIR /app + +# Copy layers in dependency order (best caching) +COPY --from=extractor /app/dependencies/ ./ +COPY --from=extractor /app/spring-boot-loader/ ./ +COPY --from=extractor /app/snapshot-dependencies/ ./ +COPY --from=extractor /app/application/ ./ + +EXPOSE 8080 + +# Optimized JVM settings for micro-containers +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=70.0", \ + "-XX:+UseG1GC", \ + "-XX:+UseStringDeduplication", \ + "-XX:+CompactStrings", \ + "-Xshare:on", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-Dspring.backgroundpreinitializer.ignore=true", \ + "org.springframework.boot.loader.JarLauncher"] diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000000000000000000000000000000000000..5135bb6fe05c48308f7b3f756fec66c7525aa6a8 --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,20 @@ +# GraalVM Native Image - Ultra-fast startup, tiny size +# Expected size: ~50-80MB, startup <100ms +# Note: Requires native compilation support in Spring Boot + +# Stage 1: Native compilation +FROM ghcr.io/graalvm/graalvm-ce:ol9-java21 as native-builder +WORKDIR /app + +# Install native-image +RUN gu install native-image + +# Copy source and build native executable +COPY . . +RUN ./gradlew nativeCompile + +# Stage 2: Minimal runtime +FROM scratch +COPY --from=native-builder /app/build/native/nativeCompile/app /app +EXPOSE 8080 +ENTRYPOINT ["/app"] diff --git a/src/main/java/com/dalab/reporting/DaReportingApplication.java b/src/main/java/com/dalab/reporting/DaReportingApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..fa82caf98391f265e84fd0a25ccc7597ec502739 --- /dev/null +++ b/src/main/java/com/dalab/reporting/DaReportingApplication.java @@ -0,0 +1,17 @@ +package com.dalab.reporting; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; // For async email/slack sending +import org.springframework.scheduling.annotation.EnableScheduling; // If scheduled report generation is needed + +@SpringBootApplication +@EnableScheduling // Optional: if reports are generated on a schedule +@EnableAsync // Optional: for non-blocking notification sending +public class DaReportingApplication { + + public static void main(String[] args) { + SpringApplication.run(DaReportingApplication.class, args); + } + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/client/CatalogServiceClient.java b/src/main/java/com/dalab/reporting/client/CatalogServiceClient.java new file mode 100644 index 0000000000000000000000000000000000000000..95e09e380ae245144e411a3170611feb896a2a52 --- /dev/null +++ b/src/main/java/com/dalab/reporting/client/CatalogServiceClient.java @@ -0,0 +1,66 @@ +package com.dalab.reporting.client; + +import java.util.Map; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Feign client for da-catalog service + * Enables cross-service communication for dashboard aggregation + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@FeignClient(name = "da-catalog", url = "${dalab.services.da-catalog.url:http://localhost:8081}") +public interface CatalogServiceClient { + + /** + * Get total asset count for governance calculations + */ + @GetMapping("/api/v1/catalog/assets/count") + Long getTotalAssetCount(); + + /** + * Get classified assets count for data classification metrics + */ + @GetMapping("/api/v1/catalog/assets/count/classified") + Long getClassifiedAssetsCount(); + + /** + * Get assets with metadata count for metadata completeness metrics + */ + @GetMapping("/api/v1/catalog/assets/count/with-metadata") + Long getAssetsWithMetadataCount(); + + /** + * Get assets with business context count for governance scoring + */ + @GetMapping("/api/v1/catalog/assets/count/with-business-context") + Long getAssetsWithBusinessContextCount(); + + /** + * Get assets with lineage count for lineage coverage metrics + */ + @GetMapping("/api/v1/catalog/assets/count/with-lineage") + Long getAssetsWithLineageCount(); + + /** + * Get total lineage connections count + */ + @GetMapping("/api/v1/catalog/assets/lineage/connections/count") + Long getTotalLineageConnectionsCount(); + + /** + * Get governance issues from catalog service + */ + @GetMapping("/api/v1/catalog/assets/governance-issues") + Map getGovernanceIssues(@RequestParam(required = false) String severity); + + /** + * Get asset classification breakdown for scoring + */ + @GetMapping("/api/v1/catalog/assets/classification-breakdown") + Map getClassificationBreakdown(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/client/PolicyEngineServiceClient.java b/src/main/java/com/dalab/reporting/client/PolicyEngineServiceClient.java new file mode 100644 index 0000000000000000000000000000000000000000..090767afb01910242201f8dd0d44d8d26940df66 --- /dev/null +++ b/src/main/java/com/dalab/reporting/client/PolicyEngineServiceClient.java @@ -0,0 +1,59 @@ +package com.dalab.reporting.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.Map; + +/** + * Feign client for da-policyengine service + * Enables cross-service communication for policy compliance data + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@FeignClient(name = "da-policyengine", url = "${dalab.services.da-policyengine.url:http://localhost:8082}") +public interface PolicyEngineServiceClient { + + /** + * Get active policies count for governance metrics + */ + @GetMapping("/api/v1/policyengine/policies/count/active") + Long getActivePoliciesCount(); + + /** + * Get policy violations count for compliance scoring + */ + @GetMapping("/api/v1/policyengine/policies/violations/count") + Long getPolicyViolationsCount(@RequestParam(required = false) String severity); + + /** + * Get policy compliance rate + */ + @GetMapping("/api/v1/policyengine/policies/compliance-rate") + Double getPolicyComplianceRate(); + + /** + * Get policy compliance breakdown by category + */ + @GetMapping("/api/v1/policyengine/policies/compliance-breakdown") + Map getPolicyComplianceBreakdown(); + + /** + * Get policy action items for aggregation + */ + @GetMapping("/api/v1/policyengine/policies/action-items") + List> getPolicyActionItems( + @RequestParam(required = false) String severity, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "50") Integer limit); + + /** + * Get historical policy compliance data + */ + @GetMapping("/api/v1/policyengine/policies/compliance-history") + List> getPolicyComplianceHistory( + @RequestParam(defaultValue = "30") Integer days); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/common/exception/BadRequestException.java b/src/main/java/com/dalab/reporting/common/exception/BadRequestException.java new file mode 100644 index 0000000000000000000000000000000000000000..7ebc38e7b70c883d06194422200800c63fe60b02 --- /dev/null +++ b/src/main/java/com/dalab/reporting/common/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package com.dalab.reporting.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } + + public BadRequestException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/common/exception/ConflictException.java b/src/main/java/com/dalab/reporting/common/exception/ConflictException.java new file mode 100644 index 0000000000000000000000000000000000000000..43ee88c003fbfa5afbb89a8701258eb1e731e7c0 --- /dev/null +++ b/src/main/java/com/dalab/reporting/common/exception/ConflictException.java @@ -0,0 +1,15 @@ +package com.dalab.reporting.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } + + public ConflictException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/common/exception/ResourceNotFoundException.java b/src/main/java/com/dalab/reporting/common/exception/ResourceNotFoundException.java new file mode 100644 index 0000000000000000000000000000000000000000..99badd7bb13ddcdb1a0b6f8e0e71b6297082d678 --- /dev/null +++ b/src/main/java/com/dalab/reporting/common/exception/ResourceNotFoundException.java @@ -0,0 +1,15 @@ +package com.dalab.reporting.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) { + super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue)); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/config/AsyncConfig.java b/src/main/java/com/dalab/reporting/config/AsyncConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..2abbc84c7fb4b35f7f87a79ff0d2f8539ef42928 --- /dev/null +++ b/src/main/java/com/dalab/reporting/config/AsyncConfig.java @@ -0,0 +1,21 @@ +package com.dalab.reporting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + @Bean + public TaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // Adjust as needed + executor.setMaxPoolSize(10); // Adjust as needed + executor.setQueueCapacity(25); // Adjust as needed + executor.setThreadNamePrefix("ReportAsync-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/config/DashboardConfig.java b/src/main/java/com/dalab/reporting/config/DashboardConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..32ba6e48addb28cfdbadc8c6330535c1cff63686 --- /dev/null +++ b/src/main/java/com/dalab/reporting/config/DashboardConfig.java @@ -0,0 +1,20 @@ +package com.dalab.reporting.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for dashboard aggregation infrastructure + * Includes basic caching and Feign clients for cross-service communication + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@Configuration +@EnableCaching +@EnableFeignClients(basePackages = "com.dalab.reporting.client") +public class DashboardConfig { + // Basic configuration for Feign clients and caching + // Redis and circuit breaker configuration can be added later +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/config/KafkaConsumerConfig.java b/src/main/java/com/dalab/reporting/config/KafkaConsumerConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..f5a703d598d1dbc7c417de8040476915e0ff2259 --- /dev/null +++ b/src/main/java/com/dalab/reporting/config/KafkaConsumerConfig.java @@ -0,0 +1,58 @@ +package com.dalab.reporting.config; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer; +import org.springframework.kafka.support.serializer.JsonDeserializer; + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id:reporting-group}") // Default group-id from properties + private String defaultGroupId; + + @Value("${spring.kafka.consumer.properties.spring.json.trusted.packages:*}") + private String trustedPackages; + + @Bean + public ConsumerFactory consumerFactory() { + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + // group-id will be set per listener, but a default can be here if needed + // props.put(ConsumerConfig.GROUP_ID_CONFIG, defaultGroupId); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + // Using ErrorHandlingDeserializer for both key and value + ErrorHandlingDeserializer keyDe = new ErrorHandlingDeserializer<>(new StringDeserializer()); + ErrorHandlingDeserializer valDe = new ErrorHandlingDeserializer<>(new JsonDeserializer<>(Object.class)); + // Configure JsonDeserializer for trusted packages + props.put(JsonDeserializer.TRUSTED_PACKAGES, trustedPackages); + props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, "false"); // If not using type headers + // If you expect specific types and don't rely on headers, you might need to configure value default type + // props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, "com.dalab.reporting.event.PolicyActionEventDTO"); + + return new DefaultKafkaConsumerFactory<>(props, keyDe, valDe); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + // Add other configurations like concurrency, error handlers, filtering, etc. if needed + // factory.setConcurrency(3); + // factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD); + return factory; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/config/OpenAPIConfiguration.java b/src/main/java/com/dalab/reporting/config/OpenAPIConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..6796c2c47c9e69fa643a386df5f9bade05957e98 --- /dev/null +++ b/src/main/java/com/dalab/reporting/config/OpenAPIConfiguration.java @@ -0,0 +1,40 @@ +package com.dalab.reporting.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; + +@Configuration +public class OpenAPIConfiguration { + + @Value("${spring.application.name:DALab Reporting Service}") + private String applicationName; + + @Bean + public OpenAPI customOpenAPI() { + final String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .info(new Info().title(applicationName) + .description("API for DALab Reporting and Notification Service. " + + "Handles report generation, management, and user notifications via various channels.") + .version("v1.2.0") // Corresponds to API Design Document version + .contact(new Contact().name("DALab Support").email("support@dalab.com")) + .license(new License().name("Apache 2.0").url("http://springdoc.org"))) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/config/SecurityConfiguration.java b/src/main/java/com/dalab/reporting/config/SecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..57562e9b5230bb23b807d28981ea7149dbdc9a47 --- /dev/null +++ b/src/main/java/com/dalab/reporting/config/SecurityConfiguration.java @@ -0,0 +1,45 @@ +package com.dalab.reporting.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) // Enables @PreAuthorize, @PostAuthorize, etc. +public class SecurityConfiguration { + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // Consider enabling CSRF with proper handling if web UI is served + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/v1/reporting/actuator/**").permitAll() // Standard Spring Boot actuators + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() // OpenAPI docs + // .requestMatchers("/public/**").permitAll() // Example for public endpoints + .anyRequest().authenticated() // All other requests require authentication + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.decoder(jwtDecoder())) + ); + return http.build(); + } + + @Bean + public JwtDecoder jwtDecoder() { + // Configure the decoder with the issuer URI from properties + // It will fetch the JWK set from the .well-known/jwks.json endpoint of the issuer + return NimbusJwtDecoder.withIssuerLocation(issuerUri).build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/controller/DashboardController.java b/src/main/java/com/dalab/reporting/controller/DashboardController.java new file mode 100644 index 0000000000000000000000000000000000000000..ca85876b486631e70b4cb4facd0dbfb9247ca1b1 --- /dev/null +++ b/src/main/java/com/dalab/reporting/controller/DashboardController.java @@ -0,0 +1,159 @@ +package com.dalab.reporting.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.dalab.common.security.SecurityUtils; +import com.dalab.reporting.dto.ActionItemsDTO; +import com.dalab.reporting.dto.CostSavingsDTO; +import com.dalab.reporting.dto.GovernanceScoreDTO; +import com.dalab.reporting.service.IDashboardService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +/** + * REST controller for DALab Dashboard Aggregation APIs + * Provides cross-service aggregation for role-based dashboards + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@RestController +@RequestMapping("/api/v1/reporting/dashboards") +@RequiredArgsConstructor +@Tag(name = "Dashboard", description = "Dashboard aggregation and analytics APIs") +public class DashboardController { + + private static final Logger log = LoggerFactory.getLogger(DashboardController.class); + + private final IDashboardService dashboardService; + + /** + * Get governance score dashboard for CDO/VP and Director roles + * Aggregates data from da-catalog, da-autocompliance, and da-policyengine + * + * @param includeHistory whether to include 30-day historical trend data + * @param includeBenchmark whether to include industry benchmark comparison + * @return comprehensive governance score with breakdown and trends + */ + @GetMapping("/cdo/governance-score") + @PreAuthorize("hasAnyRole('CDO', 'VP_DATA', 'DIRECTOR_ENGINEERING', 'DIRECTOR_PRODUCT')") + @Cacheable(value = "governanceScore", key = "#includeHistory + '_' + #includeBenchmark") + @Operation(summary = "Get governance score dashboard", + description = "Provides comprehensive governance metrics with weighted scoring across policy compliance, data classification, metadata completeness, and lineage coverage") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Governance score retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied - insufficient privileges"), + @ApiResponse(responseCode = "500", description = "Internal server error during cross-service aggregation") + }) + public ResponseEntity getGovernanceScore( + @Parameter(description = "Include 30-day historical trend data") + @RequestParam(defaultValue = "true") Boolean includeHistory, + @Parameter(description = "Include industry benchmark comparison") + @RequestParam(defaultValue = "true") Boolean includeBenchmark) { + + log.info("REST request to get governance score dashboard by user: {}", SecurityUtils.getAuthenticatedUserId()); + GovernanceScoreDTO governanceScore = dashboardService.getGovernanceScore(includeHistory, includeBenchmark); + return ResponseEntity.ok(governanceScore); + } + + /** + * Get cost savings dashboard for CDO/VP and executive roles + * Aggregates data from da-autoarchival, da-autodelete, and cloud provider APIs + * + * @param includeProjections whether to include future savings projections + * @param timeframe analysis timeframe: "30_DAYS", "90_DAYS", "12_MONTHS", "ALL_TIME" + * @return comprehensive cost savings analysis with ROI calculation + */ + @GetMapping("/cdo/cost-savings") + @PreAuthorize("hasAnyRole('CDO', 'VP_DATA', 'DIRECTOR_ENGINEERING', 'ADMIN')") + @Cacheable(value = "costSavings", key = "#includeProjections + '_' + #timeframe") + @Operation(summary = "Get cost savings dashboard", + description = "Provides comprehensive cost analysis including archival savings, deletion reclamation, and ROI calculations across multiple cloud providers") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Cost savings data retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied - insufficient privileges"), + @ApiResponse(responseCode = "500", description = "Internal server error during cost calculation"), + @ApiResponse(responseCode = "503", description = "Cloud provider API temporarily unavailable") + }) + public ResponseEntity getCostSavings( + @Parameter(description = "Include future savings projections") + @RequestParam(defaultValue = "true") Boolean includeProjections, + @Parameter(description = "Analysis timeframe") + @RequestParam(defaultValue = "12_MONTHS") String timeframe) { + + log.info("REST request to get cost savings dashboard for timeframe: {} by user: {}", timeframe, SecurityUtils.getAuthenticatedUserId()); + CostSavingsDTO costSavings = dashboardService.getCostSavings(includeProjections, timeframe); + return ResponseEntity.ok(costSavings); + } + + /** + * Get aggregated action items across all DALab services + * Aggregates data from da-autocompliance, da-policyengine, da-catalog, da-discovery + * + * @param assignedToUser filter to show only items assigned to current user + * @param severity filter by severity level: "CRITICAL", "HIGH", "MEDIUM", "LOW" + * @param status filter by status: "OPEN", "IN_PROGRESS", "PENDING_APPROVAL" + * @param limit maximum number of action items to return (default 50, max 200) + * @return comprehensive action items aggregation with SLA and business impact metrics + */ + @GetMapping("/cdo/action-items") + @PreAuthorize("hasAnyRole('CDO', 'VP_DATA', 'DIRECTOR_ENGINEERING', 'DIRECTOR_PRODUCT', 'DATA_ENGINEER', 'DATA_SCIENTIST')") + @Cacheable(value = "actionItems", key = "#assignedToUser + '_' + #severity + '_' + #status + '_' + #limit") + @Operation(summary = "Get aggregated action items dashboard", + description = "Provides comprehensive action items aggregation across all DALab services with SLA compliance metrics and business impact assessment") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Action items retrieved successfully"), + @ApiResponse(responseCode = "400", description = "Invalid filter parameters"), + @ApiResponse(responseCode = "403", description = "Access denied - insufficient privileges"), + @ApiResponse(responseCode = "500", description = "Internal server error during aggregation") + }) + public ResponseEntity getActionItems( + @Parameter(description = "Filter to show only items assigned to current user") + @RequestParam(defaultValue = "false") Boolean assignedToUser, + @Parameter(description = "Filter by severity level") + @RequestParam(required = false) String severity, + @Parameter(description = "Filter by status") + @RequestParam(required = false) String status, + @Parameter(description = "Maximum number of items to return") + @RequestParam(defaultValue = "50") Integer limit) { + + log.info("REST request to get action items dashboard with filters - assignedToUser: {}, severity: {}, status: {}, limit: {} by user: {}", + assignedToUser, severity, status, limit, SecurityUtils.getAuthenticatedUserId()); + + // Validate limit parameter + if (limit > 200) { + limit = 200; + } + + ActionItemsDTO actionItems = dashboardService.getActionItems(assignedToUser, severity, status, limit); + return ResponseEntity.ok(actionItems); + } + + /** + * Refresh dashboard cache for better performance + * Useful for scheduled cache warming or manual refresh + */ + @GetMapping("/cache/refresh") + @PreAuthorize("hasAnyRole('ADMIN', 'CDO')") + @Operation(summary = "Refresh dashboard cache", + description = "Manually triggers cache refresh for all dashboard endpoints to ensure fresh data") + @ApiResponse(responseCode = "200", description = "Cache refresh initiated successfully") + public ResponseEntity refreshDashboardCache() { + log.info("REST request to refresh dashboard cache by user: {}", SecurityUtils.getAuthenticatedUserId()); + dashboardService.refreshAllCaches(); + return ResponseEntity.ok("Dashboard cache refresh initiated successfully"); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/controller/NotificationController.java b/src/main/java/com/dalab/reporting/controller/NotificationController.java new file mode 100644 index 0000000000000000000000000000000000000000..3c257e188ceeb3328f636ce09e05cb2e611931b3 --- /dev/null +++ b/src/main/java/com/dalab/reporting/controller/NotificationController.java @@ -0,0 +1,108 @@ +package com.dalab.reporting.controller; + +import java.util.List; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +// import org.springframework.security.core.annotation.AuthenticationPrincipal; +// import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.dalab.common.security.SecurityUtils; +import com.dalab.reporting.dto.NotificationPreferenceDTO; +import com.dalab.reporting.service.INotificationService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/reporting/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private static final Logger log = LoggerFactory.getLogger(NotificationController.class); + + private final INotificationService notificationService; + + // TODO: Replace with actual authenticated user ID retrieval + private UUID getAuthenticatedUserId(/*@AuthenticationPrincipal Jwt jwt*/) { + // return UUID.fromString(jwt.getSubject()); + return UUID.randomUUID(); // Placeholder + } + + @PostMapping("/preferences") + @PreAuthorize("isAuthenticated()") // User manages their own preferences + public ResponseEntity saveNotificationPreference(@Valid @RequestBody NotificationPreferenceDTO preferenceDTO) { + // Ensure the userId in DTO matches the authenticated user, or is set by the backend + UUID authenticatedUserId = SecurityUtils.getAuthenticatedUserId(); + if (preferenceDTO.getUserId() == null) { + preferenceDTO.setUserId(authenticatedUserId); + } else if (!preferenceDTO.getUserId().equals(authenticatedUserId)) { + // Non-admins should not be able to set preferences for other users. + // Admins might have a separate endpoint or logic. + // For now, let's assume this endpoint is for the authenticated user only. + throw new org.springframework.security.access.AccessDeniedException("Cannot set preferences for another user."); + } + return new ResponseEntity<>(notificationService.saveNotificationPreference(preferenceDTO), HttpStatus.OK); + } + + @GetMapping("/preferences/my") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getMyNotificationPreferences() { + UUID userId = SecurityUtils.getAuthenticatedUserId(); + return ResponseEntity.ok(notificationService.getNotificationPreferencesByUserId(userId)); + } + + @GetMapping("/preferences/user/{userId}") + @PreAuthorize("hasRole('ADMIN')") // Admin can view others' preferences + public ResponseEntity> getUserNotificationPreferences(@PathVariable UUID userId) { + return ResponseEntity.ok(notificationService.getNotificationPreferencesByUserId(userId)); + } + + @GetMapping("/preferences/{preferenceId}") + @PreAuthorize("isAuthenticated()") // User can get their own, admin can get any + public ResponseEntity getNotificationPreferenceById(@PathVariable UUID preferenceId) { + // TODO: Add ownership check if not admin + return ResponseEntity.ok(notificationService.getNotificationPreferenceById(preferenceId)); + } + + @GetMapping("/preferences") + @PreAuthorize("hasRole('ADMIN')") // Admin only: get all preferences + public ResponseEntity> getAllNotificationPreferences(Pageable pageable) { + return ResponseEntity.ok(notificationService.getAllNotificationPreferences(pageable)); + } + + @DeleteMapping("/preferences/{preferenceId}") + @PreAuthorize("isAuthenticated()") // User can delete their own, admin can delete any + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteNotificationPreference(@PathVariable UUID preferenceId) { + // TODO: Add ownership check if not admin + notificationService.deleteNotificationPreference(preferenceId); + } + + @PostMapping("/test/{channelName}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity sendTestNotification(@PathVariable String channelName) { + UUID userId = SecurityUtils.getAuthenticatedUserId(); + notificationService.sendTestNotification(userId, channelName); + return ResponseEntity.ok("Test notification sent to user " + userId + " via " + channelName); + } + + // Note: A general purpose POST /notifications to send ad-hoc notifications is not exposed here. + // That would typically be an internal capability or a highly restricted admin API, + // as it could be misused for spam if `NotificationRequestDTO` is directly accepted. + // Notifications are primarily driven by events, report completions, or specific system actions. +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/controller/ReportController.java b/src/main/java/com/dalab/reporting/controller/ReportController.java new file mode 100644 index 0000000000000000000000000000000000000000..a2d123d6402e2c30d4f582ec5159fd0a0e620d5d --- /dev/null +++ b/src/main/java/com/dalab/reporting/controller/ReportController.java @@ -0,0 +1,121 @@ +package com.dalab.reporting.controller; + +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +// import org.springframework.security.core.annotation.AuthenticationPrincipal; +// import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.dalab.common.security.SecurityUtils; +import com.dalab.reporting.dto.GeneratedReportOutputDTO; +import com.dalab.reporting.dto.ReportDefinitionDTO; +import com.dalab.reporting.dto.ReportGenerationRequestDTO; +import com.dalab.reporting.service.IReportService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/reporting") +@RequiredArgsConstructor +public class ReportController { + + private static final Logger log = LoggerFactory.getLogger(ReportController.class); + + private final IReportService reportService; + + // --- Report Definitions --- + + @PostMapping("/definitions") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('SCOPE_manage:reports')") + public ResponseEntity createReportDefinition(@Valid @RequestBody ReportDefinitionDTO reportDefinitionDTO) { + return new ResponseEntity<>(reportService.createReportDefinition(reportDefinitionDTO), HttpStatus.CREATED); + } + + @GetMapping("/definitions/{id}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getReportDefinitionById(@PathVariable UUID id) { + return ResponseEntity.ok(reportService.getReportDefinitionById(id)); + } + + @GetMapping("/definitions/key/{reportKey}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getReportDefinitionByKey(@PathVariable String reportKey) { + return ResponseEntity.ok(reportService.getReportDefinitionByKey(reportKey)); + } + + @GetMapping("/definitions") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getAllReportDefinitions(Pageable pageable) { + return ResponseEntity.ok(reportService.getAllReportDefinitions(pageable)); + } + + @PutMapping("/definitions/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('SCOPE_manage:reports')") + public ResponseEntity updateReportDefinition(@PathVariable UUID id, @Valid @RequestBody ReportDefinitionDTO reportDefinitionDTO) { + return ResponseEntity.ok(reportService.updateReportDefinition(id, reportDefinitionDTO)); + } + + @DeleteMapping("/definitions/{id}") + @PreAuthorize("hasRole('ADMIN') or hasAuthority('SCOPE_manage:reports')") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteReportDefinition(@PathVariable UUID id) { + reportService.deleteReportDefinition(id); + } + + // --- Report Generation & Retrieval --- + + @PostMapping("/key/{reportKey}/generate") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'ROLE_ANALYST')") + public ResponseEntity generateReport( + @PathVariable String reportKey, + @RequestBody(required = false) ReportGenerationRequestDTO requestDTO) { + log.info("REST request to generate report: {}", reportKey); + UUID userId = SecurityUtils.getAuthenticatedUserId(); + GeneratedReportOutputDTO generatedReport = reportService.generateReport(reportKey, requestDTO, userId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(generatedReport); + } + + @PostMapping("/id/{id}/generate") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'ROLE_ANALYST')") + public ResponseEntity regenerateReport(@PathVariable UUID id) { + log.info("REST request to regenerate report: {}", id); + UUID userId = SecurityUtils.getAuthenticatedUserId(); + GeneratedReportOutputDTO generatedReport = reportService.regenerateReport(id, userId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(generatedReport); + } + + @GetMapping("/reports/jobs/{generatedReportId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getGeneratedReportStatus(@PathVariable UUID generatedReportId) { + // This DTO contains status, content (if ready), etc. + return ResponseEntity.ok(reportService.getGeneratedReportById(generatedReportId)); + } + + @GetMapping("/reports/key/{reportKey}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getGeneratedReportsByReportKey(@PathVariable String reportKey, Pageable pageable) { + return ResponseEntity.ok(reportService.getGeneratedReportsByReportKey(reportKey, pageable)); + } + + @GetMapping("/reports") + @PreAuthorize("isAuthenticated()") // Might restrict further based on roles for seeing ALL reports + public ResponseEntity> getAllGeneratedReports(Pageable pageable) { + return ResponseEntity.ok(reportService.getAllGeneratedReports(pageable)); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/ActionItemsDTO.java b/src/main/java/com/dalab/reporting/dto/ActionItemsDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..5453da17d21cc8d759be3313773acab5f3d8c64e --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/ActionItemsDTO.java @@ -0,0 +1,173 @@ +package com.dalab.reporting.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * DTO for Action Items Aggregation Dashboard response + * Provides comprehensive action items from all DALab services + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ActionItemsDTO { + + /** + * Total number of active action items + */ + private Integer totalActionItems; + + /** + * Action items by priority/severity + */ + private ActionItemsSummary summary; + + /** + * Detailed list of action items + */ + private List actionItems; + + /** + * SLA compliance metrics + */ + private SlaMetrics slaMetrics; + + /** + * Business impact assessment + */ + private BusinessImpactSummary businessImpact; + + /** + * Last aggregation timestamp + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime lastAggregated; + + /** + * Data sources included in aggregation + */ + private List includedServices; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ActionItemsSummary { + private Integer criticalCount; + private Integer highCount; + private Integer mediumCount; + private Integer lowCount; + + private Integer overdueCount; + private Integer dueTodayCount; + private Integer dueThisWeekCount; + + private Integer assignedCount; + private Integer unassignedCount; + private Integer inProgressCount; + private Integer completedCount; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ActionItem { + private String id; + private String title; + private String description; + private String category; // "COMPLIANCE_VIOLATION", "POLICY_BREACH", "METADATA_MISSING", "DISCOVERY_FAILED", etc. + private String severity; // "CRITICAL", "HIGH", "MEDIUM", "LOW" + private String status; // "OPEN", "IN_PROGRESS", "PENDING_APPROVAL", "COMPLETED", "DISMISSED" + + private String sourceService; // "da-autocompliance", "da-policyengine", etc. + private String sourceEntityType; // "ASSET", "POLICY", "USER", "CONNECTION" + private String sourceEntityId; + private String sourceEntityName; + + private String assignedTo; // user ID or team + private String assignedTeam; // "DATA_ENGINEERING", "COMPLIANCE", "SECURITY" + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime dueDate; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime lastUpdated; + + private String actionRequired; + private String businessJustification; + private String estimatedEffort; // "MINUTES", "HOURS", "DAYS", "WEEKS" + private Integer businessImpactScore; // 1-10 + + private List tags; + private String detailsUrl; // link to specific service UI + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SlaMetrics { + private Double averageResolutionTimeHours; + private Double slaComplianceRate; // percentage + private Integer breachedSlaCount; + private Integer nearBreachCount; // within 24 hours of SLA breach + + private SlaByCategory complianceViolations; + private SlaByCategory policyBreaches; + private SlaByCategory metadataIssues; + private SlaByCategory discoveryIssues; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SlaByCategory { + private Integer totalItems; + private Integer onTimeResolved; + private Integer breachedSla; + private Double averageResolutionHours; + private Double complianceRate; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BusinessImpactSummary { + private Integer highImpactItems; // score 8-10 + private Integer mediumImpactItems; // score 5-7 + private Integer lowImpactItems; // score 1-4 + + private Double averageImpactScore; + private String highestImpactCategory; + + private List impactAreas; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ImpactArea { + private String areaName; // "DATA_PRIVACY", "REGULATORY_COMPLIANCE", "OPERATIONAL_EFFICIENCY", "COST_OPTIMIZATION" + private Integer affectedItems; + private Double averageImpactScore; + private String riskLevel; // "HIGH", "MEDIUM", "LOW" + private String description; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/CostSavingsDTO.java b/src/main/java/com/dalab/reporting/dto/CostSavingsDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..8e4e3dc484990433586cd6e5e95477393e356086 --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/CostSavingsDTO.java @@ -0,0 +1,158 @@ +package com.dalab.reporting.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * DTO for Cost Savings Dashboard response + * Provides comprehensive cost analysis and savings opportunities + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CostSavingsDTO { + + /** + * Total cost savings achieved (USD) + */ + private BigDecimal totalSavings; + + /** + * Monthly savings rate (USD per month) + */ + private BigDecimal monthlySavingsRate; + + /** + * ROI percentage including platform costs + */ + private Double roiPercentage; + + /** + * Cost savings breakdown by category + */ + private CostSavingsBreakdown breakdown; + + /** + * Savings by cloud provider + */ + private List providerSavings; + + /** + * Projected savings pipeline + */ + private List savingsOpportunities; + + /** + * Historical savings trend (last 12 months) + */ + private List historicalTrend; + + /** + * Cost optimization recommendations + */ + private List recommendations; + + /** + * Last calculation timestamp + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime lastCalculated; + + /** + * Data accuracy indicator + */ + private String dataAccuracy; // "REAL_TIME", "DAILY", "ESTIMATED" + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CostSavingsBreakdown { + + /** + * Savings from archival operations + */ + private BigDecimal archivalSavings; + private Integer archivedAssetsCount; + private String archivalStorageUsed; // e.g., "2.5 TB" + + /** + * Savings from deletion operations + */ + private BigDecimal deletionSavings; + private Integer deletedAssetsCount; + private String storageReclaimed; // e.g., "5.2 TB" + + /** + * Savings from storage tier optimization + */ + private BigDecimal tierOptimizationSavings; + private Integer optimizedAssetsCount; + + /** + * Compute cost reductions + */ + private BigDecimal computeSavings; + private Integer decommissionedResources; + + /** + * Platform operational costs + */ + private BigDecimal platformCosts; + private BigDecimal netSavings; // totalSavings - platformCosts + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ProviderSavings { + private String providerName; // "AWS", "GCP", "Azure", "OCI" + private BigDecimal savings; + private BigDecimal monthlyRate; + private String primarySavingsSource; // "ARCHIVAL", "DELETION", "OPTIMIZATION" + private Double contributionPercentage; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SavingsOpportunity { + private String opportunityType; // "ARCHIVAL_CANDIDATE", "DELETION_CANDIDATE", "TIER_OPTIMIZATION" + private String description; + private BigDecimal potentialSavings; + private BigDecimal monthlyImpact; + private String timeframe; // "IMMEDIATE", "30_DAYS", "90_DAYS" + private String effortLevel; // "LOW", "MEDIUM", "HIGH" + private Integer affectedAssets; + private String providerName; + private String actionRequired; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SavingsTrendPoint { + @JsonFormat(pattern = "yyyy-MM") + private LocalDateTime month; + private BigDecimal cumulativeSavings; + private BigDecimal monthlySavings; + private BigDecimal platformCosts; + private BigDecimal netSavings; + private Integer assetsProcessed; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/GeneratedReportOutputDTO.java b/src/main/java/com/dalab/reporting/dto/GeneratedReportOutputDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..25bf102660d4884ac432ffdd5555c3029caa9a57 --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/GeneratedReportOutputDTO.java @@ -0,0 +1,26 @@ +package com.dalab.reporting.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.dalab.reporting.model.ReportStatus; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class GeneratedReportOutputDTO { + private UUID id; + private String reportKey; // From associated ReportDefinition + private String reportDisplayName; // From associated ReportDefinition + private ReportStatus status; + private Map generationParameters; + private Object reportContent; // Can be String (URI, CSV) or Map/Object (JSON) + private String failureReason; + private Instant requestedAt; + private Instant startedAt; + private Instant completedAt; + private UUID requestedByUserId; +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/GovernanceScoreDTO.java b/src/main/java/com/dalab/reporting/dto/GovernanceScoreDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..827eb0e2e8306a03482f73878b9c3fc1ce0a8b3e --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/GovernanceScoreDTO.java @@ -0,0 +1,143 @@ +package com.dalab.reporting.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * DTO for Governance Score Dashboard response + * Provides comprehensive governance metrics and breakdown + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GovernanceScoreDTO { + + /** + * Overall governance score (0-100) + * Weighted calculation: Policy Compliance 30%, Data Classification 25%, + * Metadata Completeness 25%, Lineage Coverage 20% + */ + private Double overallScore; + + /** + * Governance score breakdown by category + */ + private GovernanceBreakdown breakdown; + + /** + * Historical trend data (last 30 days) + */ + private List historicalTrend; + + /** + * Top governance issues requiring attention + */ + private List topIssues; + + /** + * Benchmark comparison against industry standards + */ + private BenchmarkComparison benchmark; + + /** + * Score improvement recommendations + */ + private List recommendations; + + /** + * Last calculation timestamp + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime lastCalculated; + + /** + * Data freshness indicator (minutes since last update) + */ + private Integer dataFreshnessMinutes; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class GovernanceBreakdown { + + /** + * Policy compliance score (30% weight) + */ + private Double policyComplianceScore; + private Integer activePolicies; + private Integer violationsCount; + private Double complianceRate; + + /** + * Data classification score (25% weight) + */ + private Double dataClassificationScore; + private Integer classifiedAssets; + private Integer totalAssets; + private Double classificationRate; + + /** + * Metadata completeness score (25% weight) + */ + private Double metadataCompletenessScore; + private Integer assetsWithMetadata; + private Integer assetsWithBusinessContext; + private Double metadataCompletionRate; + + /** + * Lineage coverage score (20% weight) + */ + private Double lineageCoverageScore; + private Integer assetsWithLineage; + private Integer totalLineageConnections; + private Double lineageCoverageRate; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class GovernanceTrendPoint { + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDateTime date; + private Double score; + private String period; // "daily", "weekly" + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class GovernanceIssue { + private String category; // "POLICY_VIOLATION", "MISSING_CLASSIFICATION", "INCOMPLETE_METADATA", "MISSING_LINEAGE" + private String description; + private String severity; // "HIGH", "MEDIUM", "LOW" + private Integer affectedAssets; + private String actionRequired; + private String assignedTeam; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class BenchmarkComparison { + private Double industryAverage; + private Double peerAverage; + private String ranking; // "EXCELLENT", "GOOD", "AVERAGE", "BELOW_AVERAGE", "POOR" + private Integer percentile; // 0-100 + private String comparisonNote; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/NotificationPreferenceDTO.java b/src/main/java/com/dalab/reporting/dto/NotificationPreferenceDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..02bcc80b3d6dff41c1e4fda24cd207beed3af7b0 --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/NotificationPreferenceDTO.java @@ -0,0 +1,36 @@ +package com.dalab.reporting.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.dalab.reporting.model.NotificationChannel; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class NotificationPreferenceDTO { + private UUID id; + + @NotNull(message = "User ID cannot be null") + private UUID userId; + + @NotBlank(message = "Notification type key cannot be blank") + @Size(max = 100, message = "Notification type key must be less than 100 characters") + private String notificationTypeKey; + + @NotNull(message = "Channel cannot be null") + private NotificationChannel channel; + + private boolean enabled; + + private Map channelConfiguration; + + private Instant createdAt; + private Instant updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/NotificationRequestDTO.java b/src/main/java/com/dalab/reporting/dto/NotificationRequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..2bffc2f3227b42f23ab15e394bc0397d234fdba3 --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/NotificationRequestDTO.java @@ -0,0 +1,37 @@ +package com.dalab.reporting.dto; + +import com.dalab.reporting.model.NotificationChannel; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Data +@Builder +public class NotificationRequestDTO { + // Target specific user(s) by their IDs + private List targetUserIds; + + // Or target a specific notification type key (service will find subscribed users) + private String notificationTypeKey; + + @NotEmpty(message = "At least one notification channel must be specified.") + private List channels; // e.g., [EMAIL, SLACK] + + @NotBlank(message = "Subject cannot be blank") + private String subject; + + @NotBlank(message = "Body cannot be blank") + private String body; + + // Optional: For rich content or structured data in notifications (e.g., Slack blocks, email template model) + private Map contentModel; + + // Optional: Specific channel configurations to use for this notification, overriding user preferences or defaults. + // Key: "EMAIL" or "SLACK". Value: Map, e.g. for SLACK: {"targetChannelId": "#alerts"} + private Map> channelOverrides; +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/ReportDefinitionDTO.java b/src/main/java/com/dalab/reporting/dto/ReportDefinitionDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..4b8a1bbe1e08b3e94a4861f137d13e70b285263d --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/ReportDefinitionDTO.java @@ -0,0 +1,36 @@ +package com.dalab.reporting.dto; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ReportDefinitionDTO { + private UUID id; + + @NotBlank(message = "Report key cannot be blank") + @Size(max = 100, message = "Report key must be less than 100 characters") + private String reportKey; + + @NotBlank(message = "Display name cannot be blank") + @Size(max = 255, message = "Display name must be less than 255 characters") + private String displayName; + + @Size(max = 1000, message = "Description must be less than 1000 characters") + private String description; + + private Map defaultParameters; + + @Size(max = 100, message = "Default schedule cron must be less than 100 characters") + private String defaultScheduleCron; + + private boolean enabled; + private Instant createdAt; + private Instant updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/dto/ReportGenerationRequestDTO.java b/src/main/java/com/dalab/reporting/dto/ReportGenerationRequestDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..904d7a366d914549e5e59500eb64173797d9e649 --- /dev/null +++ b/src/main/java/com/dalab/reporting/dto/ReportGenerationRequestDTO.java @@ -0,0 +1,21 @@ +package com.dalab.reporting.dto; + +import java.util.Map; +import java.util.UUID; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ReportGenerationRequestDTO { + // This DTO is primarily for triggering. For output, see GeneratedReportOutputDTO. + + // Can be used to specify parameters overriding the ReportDefinition's defaults + private Map parameters; + + // Optional: if a specific user ID should be associated with this request + // Otherwise, it might be inferred from the authenticated context or be system-initiated. + private UUID requestedByUserId; + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/event/PolicyActionEventDTO.java b/src/main/java/com/dalab/reporting/event/PolicyActionEventDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..f31d4d9424453d8f24d769e7e8659fe82258b584 --- /dev/null +++ b/src/main/java/com/dalab/reporting/event/PolicyActionEventDTO.java @@ -0,0 +1,25 @@ +package com.dalab.reporting.event; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import lombok.Data; +import lombok.NoArgsConstructor; + +// Placeholder DTO - ideally this would come from da-protos or a shared library +@Data +@NoArgsConstructor +public class PolicyActionEventDTO { + private UUID eventId; + private Instant eventTimestamp; + private String policyId; + private String policyName; + private String ruleId; // Optional, if action is rule-specific + private String ruleName; // Optional + private UUID targetAssetId; + private String actionType; // e.g., "NOTIFY_ADMIN", "AUTO_TAG", "QUARANTINE_ASSET" + private Map actionParameters; // Specifics for the action + private String evaluationStatus; // e.g., "FAIL", "WARN" + private Map evaluationDetails; // e.g., what conditions triggered it +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/kafka/PolicyActionConsumer.java b/src/main/java/com/dalab/reporting/kafka/PolicyActionConsumer.java new file mode 100644 index 0000000000000000000000000000000000000000..4084fd43f2f9045a501c635c822fe3a3e6ec1daf --- /dev/null +++ b/src/main/java/com/dalab/reporting/kafka/PolicyActionConsumer.java @@ -0,0 +1,144 @@ +package com.dalab.reporting.kafka; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Service; + +import com.dalab.reporting.dto.NotificationRequestDTO; +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.service.INotificationService; + +@Service +public class PolicyActionConsumer { + + private static final Logger log = LoggerFactory.getLogger(PolicyActionConsumer.class); + + private final INotificationService notificationService; + + @Autowired + public PolicyActionConsumer(INotificationService notificationService) { + this.notificationService = notificationService; + } + + // Updated to consume Map-based events instead of protobuf + @KafkaListener(topics = "${app.kafka.topic.policy-action-event:dalab.policies.actions}", + groupId = "${spring.kafka.consumer.group-id}") + public void handlePolicyActionEvent(@Payload Map event) { + log.info("Received PolicyActionEvent: AssetId={}, ActionType={}", + event.get("assetId"), event.get("actionType")); + + String actionType = (String) event.get("actionType"); + String policyId = (String) event.get("policyId"); + String assetId = (String) event.get("assetId"); + + if (actionType != null && actionType.toUpperCase().startsWith("NOTIFY")) { + String subject = String.format("Policy Alert on Asset %s (Policy: %s)", assetId, policyId); + + StringBuilder body = new StringBuilder(); + body.append(String.format("Policy ID '%s' triggered action: %s\n", policyId, actionType)); + body.append(String.format("Target Asset ID: %s\n", assetId)); + if (event.containsKey("evaluationId")) { + body.append(String.format("Evaluation ID: %s\n", event.get("evaluationId"))); + } + + Map params = event; + if (params != null && !params.isEmpty()) { + body.append("Action Parameters:\n"); + params.forEach((k, v) -> { + body.append(String.format(" %s: %s\n", k, valueToString(v))); + }); + } + if (event.containsKey("eventTimestamp")) { + String eventTimestamp = (String) event.get("eventTimestamp"); + body.append(String.format("Event Timestamp: %s\n", eventTimestamp)); + } + + NotificationRequestDTO.NotificationRequestDTOBuilder requestBuilder = NotificationRequestDTO.builder() + .subject(subject) + .body(body.toString()); + + Map contentModelForTemplate = new HashMap<>(); + contentModelForTemplate.put("policyId", policyId); + contentModelForTemplate.put("assetId", assetId); + contentModelForTemplate.put("actionType", actionType); + if (event.containsKey("evaluationId")) contentModelForTemplate.put("evaluationId", event.get("evaluationId")); + + Map simpleParams = new HashMap<>(); + params.forEach((k,v) -> simpleParams.put(k, valueToString(v))); + contentModelForTemplate.put("actionParameters", simpleParams); + if (event.containsKey("eventTimestamp")) { + String eventTimestamp = (String) event.get("eventTimestamp"); + contentModelForTemplate.put("eventTimestamp", eventTimestamp); + } + requestBuilder.contentModel(contentModelForTemplate); + + String notificationKeyForPolicyAlerts = "POLICY_ALERT"; + Object notificationKeyValue = params.get("notificationTypeKey"); + if (notificationKeyValue instanceof String) { + notificationKeyForPolicyAlerts = (String) notificationKeyValue; + } else { + log.warn("No specific notificationTypeKey in actionParameters for policy alert, using default key: {}", notificationKeyForPolicyAlerts); + } + requestBuilder.notificationTypeKey(notificationKeyForPolicyAlerts); + + Object channelsValue = params.get("channels"); + if (channelsValue instanceof List) { + try { + List channelNames = (List) channelsValue; + requestBuilder.channels(channelNames.stream().map(s -> NotificationChannel.valueOf(s.toUpperCase())).toList()); + } catch (Exception e) { + log.warn("Could not parse 'channels' from actionParameters, defaulting to EMAIL and SLACK. Error: {}", e.getMessage()); + requestBuilder.channels(List.of(NotificationChannel.EMAIL, NotificationChannel.SLACK)); + } + } else { + requestBuilder.channels(List.of(NotificationChannel.EMAIL, NotificationChannel.SLACK)); + } + + try { + notificationService.sendNotification(requestBuilder.build()); + log.info("Notification sent for PolicyActionEvent on assetId: {}", assetId); + } catch (Exception e) { + log.error("Failed to send notification for PolicyActionEvent on assetId {}: {}", assetId, e.getMessage(), e); + } + } + + // TODO: Add logic here to feed data into internal reporting tables based on the event. + // e.g., increment compliance failure counts, log governance actions, etc. + // This would involve new JPA entities/repositories for aggregated report data. + log.info("Further processing for PolicyActionEvent on assetId {} (e.g. report data aggregation) can be added here.", assetId); + } + + // Helper method to convert protobuf Value to String for logging/display + private String valueToString(Object value) { + if (value == null) return "null"; + if (value instanceof String) return (String) value; + if (value instanceof Number) return value.toString(); + if (value instanceof Boolean) return Boolean.toString((Boolean) value); + if (value instanceof Map) { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + ((Map) value).forEach((k, v) -> { + sb.append("\"").append(k).append("\": ").append(valueToString(v)).append(", "); + }); + sb.append("}"); + return sb.toString(); + } + if (value instanceof List) { + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (Object item : (List) value) { + sb.append(valueToString(item)).append(", "); + } + sb.append("]"); + return sb.toString(); + } + return ""; + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/mapper/NotificationPreferenceMapper.java b/src/main/java/com/dalab/reporting/mapper/NotificationPreferenceMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..952c26a32aca2d7534437245fa328f85cf0f5ad6 --- /dev/null +++ b/src/main/java/com/dalab/reporting/mapper/NotificationPreferenceMapper.java @@ -0,0 +1,20 @@ +package com.dalab.reporting.mapper; + +import java.util.List; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import com.dalab.reporting.dto.NotificationPreferenceDTO; +import com.dalab.reporting.model.UserNotificationPreference; + +@Mapper(componentModel = "spring") +public interface NotificationPreferenceMapper { + + NotificationPreferenceMapper INSTANCE = Mappers.getMapper(NotificationPreferenceMapper.class); + + NotificationPreferenceDTO toDTO(UserNotificationPreference entity); + UserNotificationPreference toEntity(NotificationPreferenceDTO dto); + List toDTOList(List entityList); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/mapper/ReportMapper.java b/src/main/java/com/dalab/reporting/mapper/ReportMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..06003c2a9f89d17cfc8f3f55b5f80494fde5dccc --- /dev/null +++ b/src/main/java/com/dalab/reporting/mapper/ReportMapper.java @@ -0,0 +1,31 @@ +package com.dalab.reporting.mapper; + +import com.dalab.reporting.dto.GeneratedReportOutputDTO; +import com.dalab.reporting.dto.ReportDefinitionDTO; +import com.dalab.reporting.model.GeneratedReport; +import com.dalab.reporting.model.ReportDefinition; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ReportMapper { + + ReportMapper INSTANCE = Mappers.getMapper(ReportMapper.class); + + // ReportDefinition Mappings + ReportDefinitionDTO toDTO(ReportDefinition entity); + ReportDefinition toEntity(ReportDefinitionDTO dto); + List toDTOList(List entityList); + + // GeneratedReport Mappings + @Mapping(source = "reportDefinition.reportKey", target = "reportKey") + @Mapping(source = "reportDefinition.displayName", target = "reportDisplayName") + GeneratedReportOutputDTO toDTO(GeneratedReport entity); + List toGeneratedReportDTOList(List entityList); + + // We typically don't map from GeneratedReportOutputDTO back to GeneratedReport entity directly, + // as creation/update paths for GeneratedReport are usually more complex and handled by services. +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/model/GeneratedReport.java b/src/main/java/com/dalab/reporting/model/GeneratedReport.java new file mode 100644 index 0000000000000000000000000000000000000000..0eddcb524dc5fb58d53af6097829716bd739297e --- /dev/null +++ b/src/main/java/com/dalab/reporting/model/GeneratedReport.java @@ -0,0 +1,146 @@ +package com.dalab.reporting.model; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; + +@Entity +@Table(name = "generated_reports") +public class GeneratedReport { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(columnDefinition = "UUID") + private UUID id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_definition_id", nullable = false) + private ReportDefinition reportDefinition; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportStatus status; + + // Parameters used for this specific generation instance + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map generationParameters; + + // Store the report content directly if it's small (e.g., JSON/CSV snippet) + // For larger reports, this might store a path/URI to the report file (e.g., S3 link) + @Lob + @JdbcTypeCode(SqlTypes.JSON) // Or SqlTypes.VARCHAR for path, or SqlTypes.BINARY for blob + @Column(columnDefinition = "jsonb") // Or TEXT / BYTEA depending on content type + private String reportContent; // Could be JSON, CSV string, or a URI + + @Column(columnDefinition = "TEXT") + private String failureReason; // If status is FAILED + + @Column(nullable = false, updatable = false) + private Instant requestedAt; // When the report generation was requested/scheduled + + private Instant startedAt; + private Instant completedAt; + + // Could be the user ID who requested it, or system if scheduled + private UUID requestedByUserId; + + // Getters and Setters + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ReportDefinition getReportDefinition() { + return reportDefinition; + } + + public void setReportDefinition(ReportDefinition reportDefinition) { + this.reportDefinition = reportDefinition; + } + + public ReportStatus getStatus() { + return status; + } + + public void setStatus(ReportStatus status) { + this.status = status; + } + + public Map getGenerationParameters() { + return generationParameters; + } + + public void setGenerationParameters(Map generationParameters) { + this.generationParameters = generationParameters; + } + + public String getReportContent() { + return reportContent; + } + + public void setReportContent(String reportContent) { + this.reportContent = reportContent; + } + + public String getFailureReason() { + return failureReason; + } + + public void setFailureReason(String failureReason) { + this.failureReason = failureReason; + } + + public Instant getRequestedAt() { + return requestedAt; + } + + public void setRequestedAt(Instant requestedAt) { + this.requestedAt = requestedAt; + } + + public Instant getStartedAt() { + return startedAt; + } + + public void setStartedAt(Instant startedAt) { + this.startedAt = startedAt; + } + + public Instant getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(Instant completedAt) { + this.completedAt = completedAt; + } + + public UUID getRequestedByUserId() { + return requestedByUserId; + } + + public void setRequestedByUserId(UUID requestedByUserId) { + this.requestedByUserId = requestedByUserId; + } + + @PrePersist + protected void onCreate() { + if (requestedAt == null) { + requestedAt = Instant.now(); + } + if (status == null) { + status = ReportStatus.PENDING; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/model/NotificationChannel.java b/src/main/java/com/dalab/reporting/model/NotificationChannel.java new file mode 100644 index 0000000000000000000000000000000000000000..0705447e5f83275c115721256feb60d5f6a600d6 --- /dev/null +++ b/src/main/java/com/dalab/reporting/model/NotificationChannel.java @@ -0,0 +1,8 @@ +package com.dalab.reporting.model; + +public enum NotificationChannel { + EMAIL, + SLACK, + // IN_APP, // Future: for notifications within a UI + // TEAMS // Future: Microsoft Teams +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/model/ReportDefinition.java b/src/main/java/com/dalab/reporting/model/ReportDefinition.java new file mode 100644 index 0000000000000000000000000000000000000000..e9af360ba941c4c4a9f4bf3e1eebc3a4d7a95ec0 --- /dev/null +++ b/src/main/java/com/dalab/reporting/model/ReportDefinition.java @@ -0,0 +1,135 @@ +package com.dalab.reporting.model; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Entity +@Table(name = "report_definitions") +public class ReportDefinition { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(columnDefinition = "UUID") + private UUID id; + + @NotBlank + @Size(max = 100) + @Column(nullable = false, unique = true) + private String reportKey; // e.g., "COST_OPTIMIZATION", "COMPLIANCE_POSTURE" + + @NotBlank + @Size(max = 255) + @Column(nullable = false) + private String displayName; + + @Size(max = 1000) + private String description; + + // Default parameters for generating this report, e.g., time range, specific filters + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map defaultParameters; + + // Default cron schedule for automated generation, can be null if only manually triggered + @Size(max = 100) + private String defaultScheduleCron; + + private boolean enabled = true; // Whether this report type is generally available + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant updatedAt; + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getReportKey() { + return reportKey; + } + + public void setReportKey(String reportKey) { + this.reportKey = reportKey; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map getDefaultParameters() { + return defaultParameters; + } + + public void setDefaultParameters(Map defaultParameters) { + this.defaultParameters = defaultParameters; + } + + public String getDefaultScheduleCron() { + return defaultScheduleCron; + } + + public void setDefaultScheduleCron(String defaultScheduleCron) { + this.defaultScheduleCron = defaultScheduleCron; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/model/ReportStatus.java b/src/main/java/com/dalab/reporting/model/ReportStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..3027a8cc0fe674b42f92d6fdb95b758aba41cb08 --- /dev/null +++ b/src/main/java/com/dalab/reporting/model/ReportStatus.java @@ -0,0 +1,9 @@ +package com.dalab.reporting.model; + +public enum ReportStatus { + PENDING, // Report generation has been requested/scheduled but not yet started + GENERATING, // Report generation is in progress + COMPLETED, // Report generation finished successfully + FAILED, // Report generation failed + CANCELLED // Report generation was cancelled +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/model/UserNotificationPreference.java b/src/main/java/com/dalab/reporting/model/UserNotificationPreference.java new file mode 100644 index 0000000000000000000000000000000000000000..3dada59182c36a0de6bc6435621230a38fe330e4 --- /dev/null +++ b/src/main/java/com/dalab/reporting/model/UserNotificationPreference.java @@ -0,0 +1,129 @@ +package com.dalab.reporting.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "user_notification_preferences", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "notification_type_key", "channel"}) + }) +public class UserNotificationPreference { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(columnDefinition = "UUID") + private UUID id; + + @NotNull + @Column(name = "user_id", nullable = false) + private UUID userId; // The ID of the user (from Keycloak or central user management) + + @NotBlank + @Size(max = 100) + @Column(name = "notification_type_key", nullable = false) + private String notificationTypeKey; // e.g., "REPORT_COMPLETED_COST_OPTIMIZATION", "POLICY_ALERT_HIGH_SEVERITY" + + @NotNull + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationChannel channel; // EMAIL, SLACK + + private boolean enabled = true; + + // Channel-specific details, e.g., for SLACK, it could be { "channelId": "C123", "userId": "U456" } + // For EMAIL, it might be overridden by a central user profile email but could allow specific addresses here. + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map channelConfiguration; // e.g., Slack channel ID, specific email address + + @Column(nullable = false, updatable = false) + private Instant createdAt; + + private Instant updatedAt; + + // Getters and Setters + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getNotificationTypeKey() { + return notificationTypeKey; + } + + public void setNotificationTypeKey(String notificationTypeKey) { + this.notificationTypeKey = notificationTypeKey; + } + + public NotificationChannel getChannel() { + return channel; + } + + public void setChannel(NotificationChannel channel) { + this.channel = channel; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Map getChannelConfiguration() { + return channelConfiguration; + } + + public void setChannelConfiguration(Map channelConfiguration) { + this.channelConfiguration = channelConfiguration; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/repository/GeneratedReportRepository.java b/src/main/java/com/dalab/reporting/repository/GeneratedReportRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..a7986e012121d9dc51e61a886b44eca896e7a1c0 --- /dev/null +++ b/src/main/java/com/dalab/reporting/repository/GeneratedReportRepository.java @@ -0,0 +1,20 @@ +package com.dalab.reporting.repository; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import com.dalab.reporting.model.GeneratedReport; +import com.dalab.reporting.model.ReportDefinition; +import com.dalab.reporting.model.ReportStatus; + +@Repository +public interface GeneratedReportRepository extends JpaRepository, JpaSpecificationExecutor { + Page findByReportDefinition(ReportDefinition reportDefinition, Pageable pageable); + Page findByStatus(ReportStatus status, Pageable pageable); + Page findByRequestedByUserId(UUID userId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/repository/ReportDefinitionRepository.java b/src/main/java/com/dalab/reporting/repository/ReportDefinitionRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..fdad6f4cfb3f2c4a05cbdb38828634f8776ecb36 --- /dev/null +++ b/src/main/java/com/dalab/reporting/repository/ReportDefinitionRepository.java @@ -0,0 +1,15 @@ +package com.dalab.reporting.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import com.dalab.reporting.model.ReportDefinition; + +@Repository +public interface ReportDefinitionRepository extends JpaRepository, JpaSpecificationExecutor { + Optional findByReportKey(String reportKey); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/repository/UserNotificationPreferenceRepository.java b/src/main/java/com/dalab/reporting/repository/UserNotificationPreferenceRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..9f92f734defa12fb984403226714115c55c4d82a --- /dev/null +++ b/src/main/java/com/dalab/reporting/repository/UserNotificationPreferenceRepository.java @@ -0,0 +1,20 @@ +package com.dalab.reporting.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.model.UserNotificationPreference; + +@Repository +public interface UserNotificationPreferenceRepository extends JpaRepository, JpaSpecificationExecutor { + List findByUserId(UUID userId); + Optional findByUserIdAndNotificationTypeKeyAndChannel(UUID userId, String notificationTypeKey, NotificationChannel channel); + List findByNotificationTypeKeyAndChannelAndEnabled(String notificationTypeKey, NotificationChannel channel, boolean enabled); + List findByUserIdAndEnabled(UUID userId, boolean enabled); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/DashboardService.java b/src/main/java/com/dalab/reporting/service/DashboardService.java new file mode 100644 index 0000000000000000000000000000000000000000..d9f815faec5928ac0f05f00ac8c172701fe16b1f --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/DashboardService.java @@ -0,0 +1,410 @@ +package com.dalab.reporting.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; + +import com.dalab.reporting.client.CatalogServiceClient; +import com.dalab.reporting.client.PolicyEngineServiceClient; +import com.dalab.reporting.dto.ActionItemsDTO; +import com.dalab.reporting.dto.CostSavingsDTO; +import com.dalab.reporting.dto.GovernanceScoreDTO; + +import lombok.RequiredArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * Implementation of dashboard aggregation service + * Provides cross-service data aggregation for role-based dashboards + * + * @author DALab Development Team + * @since 2025-01-02 + */ +@Service +@RequiredArgsConstructor +public class DashboardService implements IDashboardService { + + private static final Logger log = LoggerFactory.getLogger(DashboardService.class); + + private final CatalogServiceClient catalogServiceClient; + private final PolicyEngineServiceClient policyEngineServiceClient; + + @Override + public GovernanceScoreDTO getGovernanceScore(Boolean includeHistory, Boolean includeBenchmark) { + log.info("Calculating governance score with history: {}, benchmark: {}", includeHistory, includeBenchmark); + + try { + // TODO: Replace with real cross-service aggregation + // For Week 1, return comprehensive mock data that matches the expected structure + + // Mock governance breakdown data + GovernanceScoreDTO.GovernanceBreakdown breakdown = GovernanceScoreDTO.GovernanceBreakdown.builder() + .policyComplianceScore(85.5) + .activePolicies(12) + .violationsCount(3) + .complianceRate(92.3) + .dataClassificationScore(78.2) + .classifiedAssets(156) + .totalAssets(200) + .classificationRate(78.0) + .metadataCompletenessScore(82.1) + .assetsWithMetadata(164) + .assetsWithBusinessContext(145) + .metadataCompletionRate(82.0) + .lineageCoverageScore(71.4) + .assetsWithLineage(143) + .totalLineageConnections(287) + .lineageCoverageRate(71.5) + .build(); + + // Calculate weighted overall score: Policy 30%, Classification 25%, Metadata 25%, Lineage 20% + double overallScore = (breakdown.getPolicyComplianceScore() * 0.30) + + (breakdown.getDataClassificationScore() * 0.25) + + (breakdown.getMetadataCompletenessScore() * 0.25) + + (breakdown.getLineageCoverageScore() * 0.20); + + // Mock historical trend data (if requested) + List historicalTrend = null; + if (includeHistory) { + historicalTrend = Arrays.asList( + GovernanceScoreDTO.GovernanceTrendPoint.builder() + .date(LocalDateTime.now().minusDays(30)) + .score(75.2) + .period("daily") + .build(), + GovernanceScoreDTO.GovernanceTrendPoint.builder() + .date(LocalDateTime.now().minusDays(15)) + .score(78.8) + .period("daily") + .build(), + GovernanceScoreDTO.GovernanceTrendPoint.builder() + .date(LocalDateTime.now()) + .score(overallScore) + .period("daily") + .build() + ); + } + + // Mock top governance issues + List topIssues = Arrays.asList( + GovernanceScoreDTO.GovernanceIssue.builder() + .category("MISSING_CLASSIFICATION") + .description("44 assets lack proper data classification labels") + .severity("HIGH") + .affectedAssets(44) + .actionRequired("Assign appropriate classification labels") + .assignedTeam("DATA_ENGINEERING") + .build(), + GovernanceScoreDTO.GovernanceIssue.builder() + .category("POLICY_VIOLATION") + .description("3 active policy violations require immediate attention") + .severity("CRITICAL") + .affectedAssets(3) + .actionRequired("Review and remediate policy violations") + .assignedTeam("COMPLIANCE") + .build() + ); + + // Mock benchmark comparison (if requested) + GovernanceScoreDTO.BenchmarkComparison benchmark = null; + if (includeBenchmark) { + benchmark = GovernanceScoreDTO.BenchmarkComparison.builder() + .industryAverage(72.5) + .peerAverage(76.8) + .ranking("GOOD") + .percentile(78) + .comparisonNote("Above industry average, approaching peer group excellence") + .build(); + } + + // Mock recommendations + List recommendations = Arrays.asList( + "Focus on improving data lineage coverage to reach 80% target", + "Implement automated classification for new assets to maintain classification rate", + "Address critical policy violations within next 48 hours", + "Enhance metadata completeness for business context information" + ); + + return GovernanceScoreDTO.builder() + .overallScore(overallScore) + .breakdown(breakdown) + .historicalTrend(historicalTrend) + .topIssues(topIssues) + .benchmark(benchmark) + .recommendations(recommendations) + .lastCalculated(LocalDateTime.now()) + .dataFreshnessMinutes(2) + .build(); + + } catch (Exception e) { + log.error("Error calculating governance score", e); + throw new RuntimeException("Failed to calculate governance score: " + e.getMessage(), e); + } + } + + @Override + public CostSavingsDTO getCostSavings(Boolean includeProjections, String timeframe) { + log.info("Calculating cost savings for timeframe: {}, projections: {}", timeframe, includeProjections); + + try { + // TODO: Replace with real cost calculation from archival/deletion services and cloud APIs + // For Week 1, return comprehensive mock data + + CostSavingsDTO.CostSavingsBreakdown breakdown = CostSavingsDTO.CostSavingsBreakdown.builder() + .archivalSavings(new BigDecimal("45750.50")) + .archivedAssetsCount(234) + .archivalStorageUsed("12.8 TB") + .deletionSavings(new BigDecimal("23140.25")) + .deletedAssetsCount(89) + .storageReclaimed("8.4 TB") + .tierOptimizationSavings(new BigDecimal("8920.75")) + .optimizedAssetsCount(156) + .computeSavings(new BigDecimal("12580.30")) + .decommissionedResources(23) + .platformCosts(new BigDecimal("5200.00")) + .netSavings(new BigDecimal("85191.80")) + .build(); + + BigDecimal totalSavings = new BigDecimal("90391.80"); + BigDecimal monthlySavingsRate = new BigDecimal("7532.65"); + + List providerSavings = Arrays.asList( + CostSavingsDTO.ProviderSavings.builder() + .providerName("AWS") + .savings(new BigDecimal("38250.25")) + .monthlyRate(new BigDecimal("3187.52")) + .primarySavingsSource("ARCHIVAL") + .contributionPercentage(42.3) + .build(), + CostSavingsDTO.ProviderSavings.builder() + .providerName("GCP") + .savings(new BigDecimal("29140.35")) + .monthlyRate(new BigDecimal("2428.36")) + .primarySavingsSource("DELETION") + .contributionPercentage(32.2) + .build(), + CostSavingsDTO.ProviderSavings.builder() + .providerName("Azure") + .savings(new BigDecimal("23001.20")) + .monthlyRate(new BigDecimal("1916.77")) + .primarySavingsSource("OPTIMIZATION") + .contributionPercentage(25.5) + .build() + ); + + // Mock savings opportunities (if projections requested) + List savingsOpportunities = null; + if (includeProjections) { + savingsOpportunities = Arrays.asList( + CostSavingsDTO.SavingsOpportunity.builder() + .opportunityType("ARCHIVAL_CANDIDATE") + .description("67 datasets eligible for archival to cold storage") + .potentialSavings(new BigDecimal("18750.00")) + .monthlyImpact(new BigDecimal("1562.50")) + .timeframe("30_DAYS") + .effortLevel("LOW") + .affectedAssets(67) + .providerName("AWS") + .actionRequired("Review and approve archival candidates") + .build() + ); + } + + // Mock historical trend + List historicalTrend = Arrays.asList( + CostSavingsDTO.SavingsTrendPoint.builder() + .month(LocalDateTime.now().minusMonths(2)) + .cumulativeSavings(new BigDecimal("45230.50")) + .monthlySavings(new BigDecimal("6200.25")) + .platformCosts(new BigDecimal("4800.00")) + .netSavings(new BigDecimal("40430.50")) + .assetsProcessed(156) + .build(), + CostSavingsDTO.SavingsTrendPoint.builder() + .month(LocalDateTime.now().minusMonths(1)) + .cumulativeSavings(new BigDecimal("67820.75")) + .monthlySavings(new BigDecimal("7120.40")) + .platformCosts(new BigDecimal("5000.00")) + .netSavings(new BigDecimal("62820.75")) + .assetsProcessed(201) + .build(), + CostSavingsDTO.SavingsTrendPoint.builder() + .month(LocalDateTime.now()) + .cumulativeSavings(totalSavings) + .monthlySavings(monthlySavingsRate) + .platformCosts(new BigDecimal("5200.00")) + .netSavings(breakdown.getNetSavings()) + .assetsProcessed(323) + .build() + ); + + List recommendations = Arrays.asList( + "Prioritize archival of large datasets with low access frequency", + "Review deletion candidates for cost optimization potential", + "Consider automated tier optimization for frequently accessed data", + "Monitor platform costs vs savings ratio for ROI optimization" + ); + + double roiPercentage = (breakdown.getNetSavings().doubleValue() / breakdown.getPlatformCosts().doubleValue()) * 100; + + return CostSavingsDTO.builder() + .totalSavings(totalSavings) + .monthlySavingsRate(monthlySavingsRate) + .roiPercentage(roiPercentage) + .breakdown(breakdown) + .providerSavings(providerSavings) + .savingsOpportunities(savingsOpportunities) + .historicalTrend(historicalTrend) + .recommendations(recommendations) + .lastCalculated(LocalDateTime.now()) + .dataAccuracy("ESTIMATED") + .build(); + + } catch (Exception e) { + log.error("Error calculating cost savings", e); + throw new RuntimeException("Failed to calculate cost savings: " + e.getMessage(), e); + } + } + + @Override + public ActionItemsDTO getActionItems(Boolean assignedToUser, String severity, String status, Integer limit) { + log.info("Aggregating action items with filters - assignedToUser: {}, severity: {}, status: {}, limit: {}", + assignedToUser, severity, status, limit); + + try { + // TODO: Replace with real aggregation from all DALab services + // For Week 1, return comprehensive mock data + + ActionItemsDTO.ActionItemsSummary summary = ActionItemsDTO.ActionItemsSummary.builder() + .criticalCount(4) + .highCount(12) + .mediumCount(23) + .lowCount(8) + .overdueCount(3) + .dueTodayCount(7) + .dueThisWeekCount(15) + .assignedCount(35) + .unassignedCount(12) + .inProgressCount(18) + .completedCount(156) + .build(); + + // Mock action items + List actionItems = Arrays.asList( + ActionItemsDTO.ActionItem.builder() + .id("AI-001") + .title("Critical policy violation in customer database") + .description("PII policy violation detected in customer_data_prod table") + .category("POLICY_BREACH") + .severity("CRITICAL") + .status("OPEN") + .sourceService("da-policyengine") + .sourceEntityType("ASSET") + .sourceEntityId("asset-12345") + .sourceEntityName("customer_data_prod") + .assignedTo("john.doe@company.com") + .assignedTeam("COMPLIANCE") + .createdAt(LocalDateTime.now().minusHours(2)) + .dueDate(LocalDateTime.now().plusHours(22)) + .lastUpdated(LocalDateTime.now().minusMinutes(30)) + .actionRequired("Review and apply data masking policy") + .businessJustification("Ensure GDPR compliance for customer data") + .estimatedEffort("HOURS") + .businessImpactScore(9) + .tags(Arrays.asList("GDPR", "PII", "URGENT")) + .detailsUrl("/policy-engine/violations/POL-001") + .build(), + ActionItemsDTO.ActionItem.builder() + .id("AI-002") + .title("Missing metadata for analytics datasets") + .description("44 datasets lack business metadata and data steward assignment") + .category("METADATA_MISSING") + .severity("HIGH") + .status("IN_PROGRESS") + .sourceService("da-catalog") + .sourceEntityType("ASSET") + .sourceEntityId("batch-meta-001") + .sourceEntityName("Analytics Dataset Batch") + .assignedTo("jane.smith@company.com") + .assignedTeam("DATA_ENGINEERING") + .createdAt(LocalDateTime.now().minusDays(3)) + .dueDate(LocalDateTime.now().plusDays(4)) + .lastUpdated(LocalDateTime.now().minusHours(6)) + .actionRequired("Complete metadata forms for all analytics datasets") + .businessJustification("Improve data discoverability and governance") + .estimatedEffort("DAYS") + .businessImpactScore(6) + .tags(Arrays.asList("METADATA", "GOVERNANCE")) + .detailsUrl("/catalog/metadata-gaps/MDG-001") + .build() + ); + + // Mock SLA metrics + ActionItemsDTO.SlaMetrics slaMetrics = ActionItemsDTO.SlaMetrics.builder() + .averageResolutionTimeHours(24.5) + .slaComplianceRate(87.3) + .breachedSlaCount(6) + .nearBreachCount(3) + .complianceViolations(ActionItemsDTO.SlaByCategory.builder() + .totalItems(8) + .onTimeResolved(6) + .breachedSla(2) + .averageResolutionHours(18.5) + .complianceRate(75.0) + .build()) + .build(); + + // Mock business impact summary + ActionItemsDTO.BusinessImpactSummary businessImpact = ActionItemsDTO.BusinessImpactSummary.builder() + .highImpactItems(4) + .mediumImpactItems(23) + .lowImpactItems(20) + .averageImpactScore(6.2) + .highestImpactCategory("DATA_PRIVACY") + .impactAreas(Arrays.asList( + ActionItemsDTO.ImpactArea.builder() + .areaName("DATA_PRIVACY") + .affectedItems(8) + .averageImpactScore(8.5) + .riskLevel("HIGH") + .description("Privacy compliance and PII protection issues") + .build(), + ActionItemsDTO.ImpactArea.builder() + .areaName("OPERATIONAL_EFFICIENCY") + .affectedItems(25) + .averageImpactScore(5.2) + .riskLevel("MEDIUM") + .description("Metadata and discovery efficiency improvements") + .build() + )) + .build(); + + return ActionItemsDTO.builder() + .totalActionItems(47) + .summary(summary) + .actionItems(actionItems) + .slaMetrics(slaMetrics) + .businessImpact(businessImpact) + .lastAggregated(LocalDateTime.now()) + .includedServices(Arrays.asList("da-autocompliance", "da-policyengine", "da-catalog", "da-discovery")) + .build(); + + } catch (Exception e) { + log.error("Error aggregating action items", e); + throw new RuntimeException("Failed to aggregate action items: " + e.getMessage(), e); + } + } + + @Override + @CacheEvict(value = {"governanceScore", "costSavings", "actionItems"}, allEntries = true) + public void refreshAllCaches() { + log.info("Refreshing all dashboard caches"); + // Cache eviction handled by annotation + log.info("Dashboard caches refreshed successfully"); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/IDashboardService.java b/src/main/java/com/dalab/reporting/service/IDashboardService.java new file mode 100644 index 0000000000000000000000000000000000000000..5053ee7c91e1eae73c381d94ef9babec9809b353 --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/IDashboardService.java @@ -0,0 +1,53 @@ +package com.dalab.reporting.service; + +import com.dalab.reporting.dto.ActionItemsDTO; +import com.dalab.reporting.dto.CostSavingsDTO; +import com.dalab.reporting.dto.GovernanceScoreDTO; + +/** + * Service interface for DALab Dashboard Aggregation operations + * Provides cross-service data aggregation for role-based dashboards + * + * @author DALab Development Team + * @since 2025-01-02 + */ +public interface IDashboardService { + + /** + * Calculate comprehensive governance score with weighted metrics + * Aggregates data from da-catalog, da-autocompliance, and da-policyengine + * + * @param includeHistory whether to include 30-day historical trend data + * @param includeBenchmark whether to include industry benchmark comparison + * @return governance score with breakdown, trends, and recommendations + */ + GovernanceScoreDTO getGovernanceScore(Boolean includeHistory, Boolean includeBenchmark); + + /** + * Calculate comprehensive cost savings analysis + * Aggregates data from da-autoarchival, da-autodelete, and cloud provider APIs + * + * @param includeProjections whether to include future savings projections + * @param timeframe analysis timeframe: "30_DAYS", "90_DAYS", "12_MONTHS", "ALL_TIME" + * @return cost savings analysis with ROI calculation and opportunities + */ + CostSavingsDTO getCostSavings(Boolean includeProjections, String timeframe); + + /** + * Aggregate action items from all DALab services + * Collects data from da-autocompliance, da-policyengine, da-catalog, da-discovery + * + * @param assignedToUser filter to show only items assigned to current user + * @param severity filter by severity level + * @param status filter by status + * @param limit maximum number of action items to return + * @return aggregated action items with SLA and business impact metrics + */ + ActionItemsDTO getActionItems(Boolean assignedToUser, String severity, String status, Integer limit); + + /** + * Refresh all dashboard caches for performance optimization + * Triggers cache warming for common dashboard queries + */ + void refreshAllCaches(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/INotificationService.java b/src/main/java/com/dalab/reporting/service/INotificationService.java new file mode 100644 index 0000000000000000000000000000000000000000..f4bd38649dd44d56c290ae839f1f4f1658fefc7d --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/INotificationService.java @@ -0,0 +1,28 @@ +package com.dalab.reporting.service; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.dalab.reporting.dto.NotificationPreferenceDTO; +import com.dalab.reporting.dto.NotificationRequestDTO; + +public interface INotificationService { + + // Notification Preference Management + NotificationPreferenceDTO saveNotificationPreference(NotificationPreferenceDTO preferenceDTO); + List getNotificationPreferencesByUserId(UUID userId); + NotificationPreferenceDTO getNotificationPreferenceById(UUID preferenceId); + Page getAllNotificationPreferences(Pageable pageable); // Admin endpoint + void deleteNotificationPreference(UUID preferenceId); + + // Send Notifications + void sendNotification(NotificationRequestDTO notificationRequest); + + // Test Notification (e.g., for a user to test their setup) + // This might take a simplified DTO or just user ID and channel + void sendTestNotification(UUID userId, String channelName); // channelName like "EMAIL" or "SLACK" + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/IReportService.java b/src/main/java/com/dalab/reporting/service/IReportService.java new file mode 100644 index 0000000000000000000000000000000000000000..93dfddc20b8c8d6e802fc0715af9beebc713930e --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/IReportService.java @@ -0,0 +1,33 @@ +package com.dalab.reporting.service; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.dalab.reporting.dto.GeneratedReportOutputDTO; +import com.dalab.reporting.dto.ReportDefinitionDTO; +import com.dalab.reporting.dto.ReportGenerationRequestDTO; + +public interface IReportService { + + // Report Definition Management + ReportDefinitionDTO createReportDefinition(ReportDefinitionDTO reportDefinitionDTO); + ReportDefinitionDTO getReportDefinitionById(UUID id); + ReportDefinitionDTO getReportDefinitionByKey(String reportKey); + Page getAllReportDefinitions(Pageable pageable); + ReportDefinitionDTO updateReportDefinition(UUID id, ReportDefinitionDTO reportDefinitionDTO); + void deleteReportDefinition(UUID id); + + // Report Generation & Retrieval + GeneratedReportOutputDTO generateReport(String reportKey, ReportGenerationRequestDTO requestDTO, UUID triggeredByUserId); + GeneratedReportOutputDTO generateReport(UUID reportDefinitionId, ReportGenerationRequestDTO requestDTO, UUID triggeredByUserId); + GeneratedReportOutputDTO regenerateReport(UUID reportId, UUID triggeredByUserId); + GeneratedReportOutputDTO getGeneratedReportById(UUID generatedReportId); + Page getGeneratedReportsByReportKey(String reportKey, Pageable pageable); + Page getAllGeneratedReports(Pageable pageable); + // TODO: Add methods for querying generated reports by status, user, date range, etc. + + // Internal method for scheduled or event-triggered generation (implementation detail) + // void processReportGenerationJob(UUID generatedReportId); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/impl/NotificationService.java b/src/main/java/com/dalab/reporting/service/impl/NotificationService.java new file mode 100644 index 0000000000000000000000000000000000000000..c68c8a9198b9e6669358a8d78ac2befae339c7cf --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/impl/NotificationService.java @@ -0,0 +1,207 @@ +package com.dalab.reporting.service.impl; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import com.dalab.reporting.common.exception.BadRequestException; +import com.dalab.reporting.common.exception.ResourceNotFoundException; +import com.dalab.reporting.dto.NotificationPreferenceDTO; +import com.dalab.reporting.dto.NotificationRequestDTO; +import com.dalab.reporting.mapper.NotificationPreferenceMapper; +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.model.UserNotificationPreference; +import com.dalab.reporting.repository.UserNotificationPreferenceRepository; +import com.dalab.reporting.service.INotificationService; +import com.dalab.reporting.service.notification.INotificationProvider; + +@Service +public class NotificationService implements INotificationService { + + private static final Logger log = LoggerFactory.getLogger(NotificationService.class); + + private final UserNotificationPreferenceRepository preferenceRepository; + private final NotificationPreferenceMapper preferenceMapper; + private final Map notificationProviders; + + public NotificationService( + UserNotificationPreferenceRepository preferenceRepository, + NotificationPreferenceMapper preferenceMapper, + List providers + ) { + this.preferenceRepository = preferenceRepository; + this.preferenceMapper = preferenceMapper; + this.notificationProviders = providers.stream() + .collect(Collectors.toUnmodifiableMap(INotificationProvider::getChannelType, Function.identity())); + log.info("Initialized NotificationService with providers for channels: {}", this.notificationProviders.keySet()); + } + + @Override + @Transactional + public NotificationPreferenceDTO saveNotificationPreference(NotificationPreferenceDTO preferenceDTO) { + if (preferenceDTO.getUserId() == null || preferenceDTO.getNotificationTypeKey() == null || preferenceDTO.getChannel() == null) { + throw new BadRequestException("User ID, Notification Type Key, and Channel are required for preferences."); + } + + UserNotificationPreference preference = preferenceRepository + .findByUserIdAndNotificationTypeKeyAndChannel( + preferenceDTO.getUserId(), + preferenceDTO.getNotificationTypeKey(), + preferenceDTO.getChannel()) + .orElseGet(() -> preferenceMapper.toEntity(preferenceDTO)); + + // Update fields if it exists + if (preference.getId() != null) { + preference.setEnabled(preferenceDTO.isEnabled()); + preference.setChannelConfiguration(preferenceDTO.getChannelConfiguration()); + } + // For new preferences, mapper already set the values from DTO. + + return preferenceMapper.toDTO(preferenceRepository.save(preference)); + } + + @Override + @Transactional(readOnly = true) + public List getNotificationPreferencesByUserId(UUID userId) { + return preferenceMapper.toDTOList(preferenceRepository.findByUserId(userId)); + } + + @Override + @Transactional(readOnly = true) + public NotificationPreferenceDTO getNotificationPreferenceById(UUID preferenceId) { + return preferenceRepository.findById(preferenceId) + .map(preferenceMapper::toDTO) + .orElseThrow(() -> new ResourceNotFoundException("NotificationPreference", "id", preferenceId)); + } + + @Override + @Transactional(readOnly = true) + public Page getAllNotificationPreferences(Pageable pageable) { + return preferenceRepository.findAll(pageable).map(preferenceMapper::toDTO); + } + + @Override + @Transactional + public void deleteNotificationPreference(UUID preferenceId) { + if (!preferenceRepository.existsById(preferenceId)) { + throw new ResourceNotFoundException("NotificationPreference", "id", preferenceId); + } + preferenceRepository.deleteById(preferenceId); + } + + @Override + public void sendNotification(NotificationRequestDTO request) { + if (CollectionUtils.isEmpty(request.getChannels())) { + log.warn("No channels specified in notification request. Subject: {}", request.getSubject()); + return; + } + + List preferences = Collections.emptyList(); + + if (!CollectionUtils.isEmpty(request.getTargetUserIds())) { + // Fetch preferences for specific users and filter by requested channels + preferences = preferenceRepository.findAllById(request.getTargetUserIds()).stream() + .filter(UserNotificationPreference::isEnabled) + .filter(p -> request.getChannels().contains(p.getChannel())) + .collect(Collectors.toList()); + // We might also need to handle cases where a user ID is given but they have no matching preference for the channel. + // This current logic assumes we only notify based on *existing* preferences. + // If direct notification to user without preference is needed, that's a different path. + + } else if (request.getNotificationTypeKey() != null) { + // Fetch all enabled preferences for the given notification type key and requested channels + preferences = request.getChannels().stream() + .flatMap(channel -> + preferenceRepository.findByNotificationTypeKeyAndChannelAndEnabled( + request.getNotificationTypeKey(), channel, true).stream()) + .distinct() // Avoid duplicate if user subscribed via multiple ways to same effective outcome (though our model prevents this row-wise) + .collect(Collectors.toList()); + } else { + log.warn("Notification request must specify targetUserIds or a notificationTypeKey. Subject: {}", request.getSubject()); + return; + } + + if (CollectionUtils.isEmpty(preferences)) { + log.info("No matching user preferences found for notification. Subject: {}", request.getSubject()); + return; + } + + for (UserNotificationPreference preference : preferences) { + INotificationProvider provider = notificationProviders.get(preference.getChannel()); + if (provider != null) { + try { + // Apply overrides if present + NotificationRequestDTO finalRequest = request; + if (request.getChannelOverrides() != null && request.getChannelOverrides().containsKey(preference.getChannel().name())) { + // This is a simplification. A real override might need deeper merging. + // For now, let's assume direct send is used if overrides are complex. + log.warn("Channel specific overrides are present but not fully implemented for preference-based send. Consider direct send. Channel: {}", preference.getChannel()); + } + provider.send(preference, finalRequest); + log.info("Sent notification '{}' to user {} via {}", request.getSubject(), preference.getUserId(), preference.getChannel()); + } catch (Exception e) { + log.error("Failed to send notification to user {} via {}. Subject: {}. Error: {}", + preference.getUserId(), preference.getChannel(), request.getSubject(), e.getMessage(), e); + } + } + } + } + + @Override + public void sendTestNotification(UUID userId, String channelName) { + NotificationChannel channel; + try { + channel = NotificationChannel.valueOf(channelName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid notification channel: " + channelName); + } + + INotificationProvider provider = notificationProviders.get(channel); + if (provider == null) { + throw new BadRequestException("No provider configured for channel: " + channelName); + } + + // For a test, we might fetch a user's primary contact for that channel or use a default test address. + // Here, we'll try to find a preference or send to a default if not found. + UserNotificationPreference testPreference = preferenceRepository + .findByUserIdAndNotificationTypeKeyAndChannel(userId, "TEST_NOTIFICATION", channel) + .orElseGet(() -> { + UserNotificationPreference tempPref = new UserNotificationPreference(); + tempPref.setUserId(userId); + tempPref.setChannel(channel); + tempPref.setEnabled(true); + // tempPref.setChannelConfiguration(...); // Potentially set a test email/slackId if not found in profile + return tempPref; + }); + + if (!testPreference.isEnabled()) { + throw new BadRequestException("Test notifications are disabled for this user and channel through preferences."); + } + + NotificationRequestDTO testRequest = NotificationRequestDTO.builder() + .subject("Test Notification") + .body("This is a test notification from DALab Reporting Service.") + .channels(Collections.singletonList(channel)) + .targetUserIds(Collections.singletonList(userId)) // For context, though provider.send uses preference + .build(); + + try { + provider.send(testPreference, testRequest); + log.info("Sent test notification to user {} via {}", userId, channel); + } catch (Exception e) { + log.error("Failed to send test notification to user {} via {}: {}", userId, channel, e.getMessage(), e); + throw new RuntimeException("Failed to send test notification: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/impl/ReportService.java b/src/main/java/com/dalab/reporting/service/impl/ReportService.java new file mode 100644 index 0000000000000000000000000000000000000000..f1b99865a458f051bdd25c0994c8cc4f5ea8c0af --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/impl/ReportService.java @@ -0,0 +1,230 @@ +package com.dalab.reporting.service.impl; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dalab.reporting.common.exception.BadRequestException; +import com.dalab.reporting.common.exception.ResourceNotFoundException; +import com.dalab.reporting.dto.GeneratedReportOutputDTO; +import com.dalab.reporting.dto.ReportDefinitionDTO; +import com.dalab.reporting.dto.ReportGenerationRequestDTO; +import com.dalab.reporting.mapper.ReportMapper; +import com.dalab.reporting.model.GeneratedReport; +import com.dalab.reporting.model.ReportDefinition; +import com.dalab.reporting.model.ReportStatus; +import com.dalab.reporting.repository.GeneratedReportRepository; +import com.dalab.reporting.repository.ReportDefinitionRepository; +import com.dalab.reporting.service.IReportService; + +@Service +public class ReportService implements IReportService { + + private static final Logger log = LoggerFactory.getLogger(ReportService.class); + + private final ReportDefinitionRepository reportDefinitionRepository; + private final GeneratedReportRepository generatedReportRepository; + private final ReportMapper reportMapper; + // In a real app, this might be a ApplicationEventPublisher to trigger async job via Spring Events + // private final ApplicationEventPublisher eventPublisher; + + public ReportService(ReportDefinitionRepository reportDefinitionRepository, + GeneratedReportRepository generatedReportRepository, + ReportMapper reportMapper) { + this.reportDefinitionRepository = reportDefinitionRepository; + this.generatedReportRepository = generatedReportRepository; + this.reportMapper = reportMapper; + } + + @Override + @Transactional + public ReportDefinitionDTO createReportDefinition(ReportDefinitionDTO reportDefinitionDTO) { + if (reportDefinitionRepository.findByReportKey(reportDefinitionDTO.getReportKey()).isPresent()) { + throw new BadRequestException("Report definition with key '" + reportDefinitionDTO.getReportKey() + "' already exists."); + } + ReportDefinition entity = reportMapper.toEntity(reportDefinitionDTO); + entity.setEnabled(true); // Default to enabled + return reportMapper.toDTO(reportDefinitionRepository.save(entity)); + } + + @Override + @Transactional(readOnly = true) + public ReportDefinitionDTO getReportDefinitionById(UUID id) { + return reportDefinitionRepository.findById(id) + .map(reportMapper::toDTO) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "id", id)); + } + + @Override + @Transactional(readOnly = true) + public ReportDefinitionDTO getReportDefinitionByKey(String reportKey) { + return reportDefinitionRepository.findByReportKey(reportKey) + .map(reportMapper::toDTO) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "reportKey", reportKey)); + } + + @Override + @Transactional(readOnly = true) + public Page getAllReportDefinitions(Pageable pageable) { + return reportDefinitionRepository.findAll(pageable).map(reportMapper::toDTO); + } + + @Override + @Transactional + public ReportDefinitionDTO updateReportDefinition(UUID id, ReportDefinitionDTO reportDefinitionDTO) { + ReportDefinition existingDefinition = reportDefinitionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "id", id)); + + // Check if reportKey is being changed and if it conflicts + if (!existingDefinition.getReportKey().equals(reportDefinitionDTO.getReportKey()) && + reportDefinitionRepository.findByReportKey(reportDefinitionDTO.getReportKey()).isPresent()) { + throw new BadRequestException("Report definition with key '" + reportDefinitionDTO.getReportKey() + "' already exists."); + } + + existingDefinition.setReportKey(reportDefinitionDTO.getReportKey()); + existingDefinition.setDisplayName(reportDefinitionDTO.getDisplayName()); + existingDefinition.setDescription(reportDefinitionDTO.getDescription()); + existingDefinition.setDefaultParameters(reportDefinitionDTO.getDefaultParameters()); + existingDefinition.setDefaultScheduleCron(reportDefinitionDTO.getDefaultScheduleCron()); + existingDefinition.setEnabled(reportDefinitionDTO.isEnabled()); + + return reportMapper.toDTO(reportDefinitionRepository.save(existingDefinition)); + } + + @Override + @Transactional + public void deleteReportDefinition(UUID id) { + if (!reportDefinitionRepository.existsById(id)) { + throw new ResourceNotFoundException("ReportDefinition", "id", id); + } + // TODO: Add logic to handle existing GeneratedReport instances if a definition is deleted. + // E.g., mark them as orphaned, disallow new generations, or prevent deletion if reports exist. + reportDefinitionRepository.deleteById(id); + } + + @Override + @Transactional + public GeneratedReportOutputDTO generateReport(String reportKey, ReportGenerationRequestDTO requestDTO, UUID triggeredByUserId) { + ReportDefinition reportDefinition = reportDefinitionRepository.findByReportKey(reportKey) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "reportKey", reportKey)); + return createAndQueueReportGeneration(reportDefinition, requestDTO, triggeredByUserId); + } + + @Override + @Transactional + public GeneratedReportOutputDTO generateReport(UUID reportDefinitionId, ReportGenerationRequestDTO requestDTO, UUID triggeredByUserId) { + ReportDefinition reportDefinition = reportDefinitionRepository.findById(reportDefinitionId) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "id", reportDefinitionId)); + return createAndQueueReportGeneration(reportDefinition, requestDTO, triggeredByUserId); + } + + private GeneratedReportOutputDTO createAndQueueReportGeneration(ReportDefinition reportDefinition, ReportGenerationRequestDTO requestDTO, UUID triggeredByUserId) { + if (!reportDefinition.isEnabled()) { + throw new BadRequestException("Report definition '" + reportDefinition.getReportKey() + "' is disabled."); + } + + GeneratedReport generatedReport = new GeneratedReport(); + generatedReport.setReportDefinition(reportDefinition); + generatedReport.setStatus(ReportStatus.PENDING); + generatedReport.setRequestedByUserId(triggeredByUserId); + generatedReport.setRequestedAt(Instant.now()); + + Map effectiveParams = new HashMap<>(); + if (reportDefinition.getDefaultParameters() != null) { + effectiveParams.putAll(reportDefinition.getDefaultParameters()); + } + if (requestDTO != null && requestDTO.getParameters() != null) { + effectiveParams.putAll(requestDTO.getParameters()); + } + generatedReport.setGenerationParameters(effectiveParams); + + GeneratedReport savedReport = generatedReportRepository.save(generatedReport); + log.info("Queued report generation for reportKey: {}, generatedReportId: {}", reportDefinition.getReportKey(), savedReport.getId()); + + // Trigger asynchronous processing of the report + // This could be publishing an event, sending a message to a queue, or calling an @Async method directly. + processReportGenerationJobAsync(savedReport.getId(), reportDefinition.getReportKey(), effectiveParams); + + return reportMapper.toDTO(savedReport); + } + + @Async("taskExecutor") // Assuming a TaskExecutor bean is configured for async tasks + public void processReportGenerationJobAsync(UUID generatedReportId, String reportKey, Map parameters) { + log.info("Starting asynchronous processing for generatedReportId: {}, reportKey: {}", generatedReportId, reportKey); + GeneratedReport report = generatedReportRepository.findById(generatedReportId) + .orElseThrow(() -> new ResourceNotFoundException("GeneratedReport", "id", generatedReportId)); // Should not happen if just saved + + report.setStatus(ReportStatus.GENERATING); + report.setStartedAt(Instant.now()); + generatedReportRepository.save(report); + + try { + // Simulate report generation logic based on reportKey + // In a real application, this would involve fetching data from Kafka, other services (via Feign), DB, etc. + // and then compiling it into the reportContent. + log.info("Simulating report generation for: {} with params: {}", reportKey, parameters); + Thread.sleep(5000); // Simulate work + + String reportContent = "Report content for " + reportKey + " generated at " + Instant.now() + " with params: " + parameters.toString(); + // For complex reports, content could be JSON, CSV, or a link to a file. + // Example for JSON: + // Map jsonData = new HashMap<>(); + // jsonData.put("title", "Sample Report: " + reportKey); + // jsonData.put("dataPoints", List.of(1,2,3,4,5)); + // reportContent = new ObjectMapper().writeValueAsString(jsonData); + + report.setReportContent(reportContent); + report.setStatus(ReportStatus.COMPLETED); + } catch (Exception e) { + log.error("Error during report generation for ID {}: {}", generatedReportId, e.getMessage(), e); + report.setStatus(ReportStatus.FAILED); + report.setFailureReason(e.getMessage()); + } finally { + report.setCompletedAt(Instant.now()); + generatedReportRepository.save(report); + log.info("Finished processing for generatedReportId: {}. Status: {}", generatedReportId, report.getStatus()); + // TODO: Trigger notifications if configured (e.g., on completion or failure) + } + } + + @Override + @Transactional(readOnly = true) + public GeneratedReportOutputDTO getGeneratedReportById(UUID generatedReportId) { + return generatedReportRepository.findById(generatedReportId) + .map(reportMapper::toDTO) + .orElseThrow(() -> new ResourceNotFoundException("GeneratedReport", "id", generatedReportId)); + } + + @Override + @Transactional(readOnly = true) + public Page getGeneratedReportsByReportKey(String reportKey, Pageable pageable) { + ReportDefinition reportDefinition = reportDefinitionRepository.findByReportKey(reportKey) + .orElseThrow(() -> new ResourceNotFoundException("ReportDefinition", "reportKey", reportKey)); + return generatedReportRepository.findByReportDefinition(reportDefinition, pageable).map(reportMapper::toDTO); + } + + @Override + @Transactional(readOnly = true) + public Page getAllGeneratedReports(Pageable pageable) { + return generatedReportRepository.findAll(pageable).map(reportMapper::toDTO); + } + + @Override + @Transactional + public GeneratedReportOutputDTO regenerateReport(UUID reportId, UUID triggeredByUserId) { + GeneratedReport existingReport = generatedReportRepository.findById(reportId) + .orElseThrow(() -> new ResourceNotFoundException("GeneratedReport", "id", reportId)); + + // Create a new report generation based on the existing report's definition + return createAndQueueReportGeneration(existingReport.getReportDefinition(), null, triggeredByUserId); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/notification/IEmailNotificationProvider.java b/src/main/java/com/dalab/reporting/service/notification/IEmailNotificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..9fd0fa8c08f1971dee919b04335cf0c78d7a4450 --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/notification/IEmailNotificationProvider.java @@ -0,0 +1,5 @@ +package com.dalab.reporting.service.notification; + +public interface IEmailNotificationProvider extends INotificationProvider { + // Potentially add email-specific methods here if needed in the future +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/notification/INotificationProvider.java b/src/main/java/com/dalab/reporting/service/notification/INotificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..4bede514706b02df1399c621ba27abf9edd07b2f --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/notification/INotificationProvider.java @@ -0,0 +1,11 @@ +package com.dalab.reporting.service.notification; + +import com.dalab.reporting.dto.NotificationRequestDTO; +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.model.UserNotificationPreference; + +public interface INotificationProvider { + NotificationChannel getChannelType(); + void send(UserNotificationPreference preference, NotificationRequestDTO request); // Send based on user preference + void sendDirect(NotificationRequestDTO request, String targetAddress); // Send directly, e.g. email address or Slack channel ID +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/notification/ISlackNotificationProvider.java b/src/main/java/com/dalab/reporting/service/notification/ISlackNotificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..4328c51a8152e3c2dc88cf92644659c4b4037067 --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/notification/ISlackNotificationProvider.java @@ -0,0 +1,6 @@ +package com.dalab.reporting.service.notification; + +public interface ISlackNotificationProvider extends INotificationProvider { + // Potentially add Slack-specific methods here if needed in the future + // e.g., methods to format messages using Slack's Block Kit +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/notification/impl/EmailNotificationProvider.java b/src/main/java/com/dalab/reporting/service/notification/impl/EmailNotificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..e3dd4d5dd032640c98ae433fbc454df1e7c16eee --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/notification/impl/EmailNotificationProvider.java @@ -0,0 +1,92 @@ +package com.dalab.reporting.service.notification.impl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import com.dalab.reporting.dto.NotificationRequestDTO; +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.model.UserNotificationPreference; +import com.dalab.reporting.service.notification.INotificationProvider; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +@Service +public class EmailNotificationProvider implements INotificationProvider { + + private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class); + + private final JavaMailSender mailSender; + + @Value("${app.notifications.email.from:noreply@dalab.com}") + private String fromEmail; + + // TODO: Need a way to get the actual email address for a user. + // This could be from UserNotificationPreference.channelConfiguration, + // or by calling a central User Profile service if one exists. + private static final String DEFAULT_USER_EMAIL_KEY = "emailAddress"; // Key in UserNotificationPreference.channelConfiguration + + @Autowired + public EmailNotificationProvider(JavaMailSender mailSender) { + this.mailSender = mailSender; + } + + @Override + public NotificationChannel getChannelType() { + return NotificationChannel.EMAIL; + } + + @Async + @Override + public void send(UserNotificationPreference preference, NotificationRequestDTO request) { + String toEmail = determineEmailAddress(preference); + if (toEmail == null) { + log.warn("No email address found for user {} in preference {}. Cannot send email notification.", + preference.getUserId(), preference.getId()); + return; + } + sendEmail(toEmail, request.getSubject(), request.getBody(), request.getContentModel() != null); // Assuming HTML if contentModel exists + } + + @Async + @Override + public void sendDirect(NotificationRequestDTO request, String targetEmailAddress) { + sendEmail(targetEmailAddress, request.getSubject(), request.getBody(), request.getContentModel() != null); + } + + private String determineEmailAddress(UserNotificationPreference preference) { + if (preference.getChannelConfiguration() != null && preference.getChannelConfiguration().containsKey(DEFAULT_USER_EMAIL_KEY)) { + return preference.getChannelConfiguration().get(DEFAULT_USER_EMAIL_KEY); + } + // Fallback: query a user service? For now, return null if not in preference. + log.warn("Email address not found in preference channelConfiguration for user {}.", preference.getUserId()); + return null; + } + + private void sendEmail(String to, String subject, String body, boolean isHtml) { + try { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); // true for multipart + + helper.setFrom(fromEmail); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(body, isHtml); // if isHtml is true, body should be HTML content + + // TODO: Add support for attachments or more complex HTML templates using contentModel if needed + + mailSender.send(mimeMessage); + log.info("Email sent to {} with subject: {}", to, subject); + } catch (MailException | MessagingException e) { + log.error("Failed to send email to {} with subject: {}. Error: {}", to, subject, e.getMessage(), e); + // Depending on the exception, might re-throw or handle (e.g., retry logic for MailSendException) + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/reporting/service/notification/impl/SlackNotificationProvider.java b/src/main/java/com/dalab/reporting/service/notification/impl/SlackNotificationProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..b0caebbd69592a57c3568f0615d8d373e5b1d9a0 --- /dev/null +++ b/src/main/java/com/dalab/reporting/service/notification/impl/SlackNotificationProvider.java @@ -0,0 +1,123 @@ +package com.dalab.reporting.service.notification.impl; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.dalab.reporting.dto.NotificationRequestDTO; +import com.dalab.reporting.model.NotificationChannel; +import com.dalab.reporting.model.UserNotificationPreference; +import com.dalab.reporting.service.notification.INotificationProvider; +import com.slack.api.Slack; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.SectionBlock; +import com.slack.api.model.block.composition.MarkdownTextObject; + +@Service +public class SlackNotificationProvider implements INotificationProvider { + + private static final Logger log = LoggerFactory.getLogger(SlackNotificationProvider.class); + + private final MethodsClient slackClient; + + @Value("${app.notifications.slack.bot-token:}") + private String slackBotToken; + + @Value("${app.notifications.slack.default-channel:#general}") + private String defaultSlackChannel; + + // Keys for UserNotificationPreference.channelConfiguration + private static final String SLACK_CHANNEL_ID_KEY = "channelId"; + private static final String SLACK_USER_ID_KEY = "userId"; // For DMs + + public SlackNotificationProvider(@Value("${app.notifications.slack.bot-token:}") String token) { + if (StringUtils.hasText(token)) { + this.slackClient = Slack.getInstance().methods(token); + log.info("SlackNotificationProvider initialized with a token."); + } else { + this.slackClient = null; + log.warn("SlackNotificationProvider initialized WITHOUT a token. Slack notifications will be disabled."); + } + this.slackBotToken = token; // Ensure field is set + } + + @Override + public NotificationChannel getChannelType() { + return NotificationChannel.SLACK; + } + + @Async + @Override + public void send(UserNotificationPreference preference, NotificationRequestDTO request) { + if (slackClient == null || !StringUtils.hasText(slackBotToken)) { + log.warn("Slack client not initialized or token missing; cannot send notification. Subject: {}", request.getSubject()); + return; + } + + String targetChannelId = determineSlackTarget(preference); + if (targetChannelId == null) { + log.warn("No Slack target (channel/user) found for user {} in preference {}. Cannot send Slack notification.", + preference.getUserId(), preference.getId()); + return; + } + postMessage(targetChannelId, request.getSubject(), request.getBody(), request.getContentModel()); + } + + @Async + @Override + public void sendDirect(NotificationRequestDTO request, String targetChannelOrUserId) { + if (slackClient == null || !StringUtils.hasText(slackBotToken)) { + log.warn("Slack client not initialized or token missing; cannot send direct notification. Subject: {}", request.getSubject()); + return; + } + postMessage(targetChannelOrUserId, request.getSubject(), request.getBody(), request.getContentModel()); + } + + private String determineSlackTarget(UserNotificationPreference preference) { + if (preference.getChannelConfiguration() != null) { + if (preference.getChannelConfiguration().containsKey(SLACK_USER_ID_KEY)) { + return preference.getChannelConfiguration().get(SLACK_USER_ID_KEY); // DM to user + } + if (preference.getChannelConfiguration().containsKey(SLACK_CHANNEL_ID_KEY)) { + return preference.getChannelConfiguration().get(SLACK_CHANNEL_ID_KEY); // Post to channel + } + } + log.warn("Slack target (userId or channelId) not found in preference channelConfiguration for user {}. Using default channel: {}", + preference.getUserId(), defaultSlackChannel); + return defaultSlackChannel; // Fallback to default channel if specific target not in preference + } + + private void postMessage(String channelId, String subject, String body, Object contentModel) { + try { + List blocks = new ArrayList<>(); + // Basic formatting: Subject as header (kind of), body as section + blocks.add(SectionBlock.builder().text(MarkdownTextObject.builder().text("*" + subject + "*").build()).build()); + blocks.add(SectionBlock.builder().text(MarkdownTextObject.builder().text(body).build()).build()); + + // TODO: More sophisticated block building based on contentModel if provided + // Example: if contentModel has "blocks", use those directly + // if (contentModel instanceof List) { ... build blocks ... } + + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(subject + ": " + body) // Fallback text for notifications + .blocks(blocks) + .build(); + + slackClient.chatPostMessage(request); + log.info("Slack message sent to channel/user {} with subject: {}", channelId, subject); + } catch (IOException | SlackApiException e) { + log.error("Failed to send Slack message to {} with subject: {}. Error: {}", channelId, subject, e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..2147b5bd9fa7e56be0b4ff91bea9b8b92b2b9087 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,105 @@ +# ========================================= +# DALab Reporting Service Configuration +# ========================================= + +# Application +spring.application.name=da-reporting +spring.application.description=DALab Reporting & Notification Microservice +spring.application.version=1.0.0 + +# Server Configuration +server.port=8080 +server.servlet.context-path=/api/v1/reporting +management.server.port=8380 + +# Database Configuration +# Primary database for da-reporting service +spring.datasource.url=jdbc:postgresql://localhost:5432/da_reporting +spring.datasource.username=da_reporting_user +spring.datasource.password=reporting_secure_2024 +spring.datasource.driver-class-name=org.postgresql.Driver + +# Additional database for common entities (read-only) +spring.datasource.common.url=jdbc:postgresql://localhost:5432/dalab_common +spring.datasource.common.username=da_reporting_user +spring.datasource.common.password=reporting_secure_2024 +spring.datasource.common.driver-class-name=org.postgresql.Driver + +# JPA Configuration +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true + +# Redis Configuration for Dashboard Caching +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.data.redis.password= +spring.data.redis.timeout=2000ms +spring.data.redis.database=0 +spring.cache.type=redis +spring.cache.redis.time-to-live=900000 + +# Kafka Configuration +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=da-reporting-consumer-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer +spring.kafka.consumer.properties.spring.json.trusted.packages=* +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer + +# Kafka Topics +app.kafka.topic.policy-action-event=dalab.policies.actions + +# Cross-Service Communication URLs for Dashboard Aggregation +dalab.services.da-catalog.url=http://localhost:8082 +dalab.services.da-policyengine.url=http://localhost:8083 +dalab.services.da-autocompliance.url=http://localhost:8088 +dalab.services.da-autoarchival.url=http://localhost:8086 +dalab.services.da-autodelete.url=http://localhost:8087 +dalab.services.da-discovery.url=http://localhost:8081 + +# Circuit Breaker Configuration +resilience4j.circuitbreaker.instances.da-catalog.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.da-catalog.wait-duration-in-open-state=30s +resilience4j.circuitbreaker.instances.da-catalog.sliding-window-size=10 +resilience4j.circuitbreaker.instances.da-policyengine.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.da-policyengine.wait-duration-in-open-state=30s +resilience4j.circuitbreaker.instances.da-policyengine.sliding-window-size=10 + +# Security Configuration - Keycloak JWT +spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8180/realms/dalab +spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8180/realms/dalab/protocol/openid-connect/certs + +# Mail Configuration (placeholder - update with real SMTP settings) +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${SMTP_USERNAME:your-email@gmail.com} +spring.mail.password=${SMTP_PASSWORD:your-app-password} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# Slack Configuration (placeholder) +slack.bot.token=${SLACK_BOT_TOKEN:xoxb-your-slack-bot-token} +slack.app.token=${SLACK_APP_TOKEN:xapp-your-slack-app-token} + +# Management Endpoints +management.endpoints.web.exposure.include=health,info,metrics,prometheus +management.endpoint.health.show-details=always +management.health.defaults.enabled=true +management.health.diskspace.enabled=true + +# Logging +logging.level.com.dalab.reporting=DEBUG +logging.level.org.springframework.kafka=WARN +logging.level.org.springframework.security=WARN +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n +logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + +# OpenAPI Documentation +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.enabled=true \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000000000000000000000000000000000000..53e0b63e36ecbf4c0671df58bc3c2a890fed9817 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,111 @@ +# Test configuration for da-reporting integration tests +spring: + application: + name: da-reporting-test + + # Database configuration (using H2 for tests) + datasource: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: password + hikari: + auto-commit: false + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: false + properties: + hibernate: + format_sql: false + jdbc: + lob: + non_contextual_creation: true + dialect: org.hibernate.dialect.H2Dialect + + h2: + console: + enabled: false + + # Disable Liquibase for tests + liquibase: + enabled: false + + # Disable Docker for tests + docker: + compose: + enabled: false + + # Task execution configuration for tests + task: + execution: + pool: + core-size: 2 + max-size: 4 + queue-capacity: 100 + thread-name-prefix: test-task- + + # Cache configuration for tests + cache: + type: simple + +# Disable Docker for tests +testcontainers: + enabled: false + +# Security configuration for tests +dalab: + security: + jwt: + enabled: false + oauth2: + enabled: false + +# Kafka configuration for tests (disabled) +spring: + kafka: + enabled: false + bootstrap-servers: localhost:9092 + consumer: + group-id: da-reporting-test + auto-offset-reset: earliest + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + +# OpenFeign configuration for tests (mocked) +feign: + client: + config: + default: + connect-timeout: 5000 + read-timeout: 5000 + logger-level: basic + +# Service URLs for testing (mocked) +dalab: + services: + da-catalog: + url: http://localhost:8082 + da-policyengine: + url: http://localhost:8083 + da-autolabel: + url: http://localhost:8084 + da-autoarchival: + url: http://localhost:8085 + da-autodelete: + url: http://localhost:8086 + da-autocompliance: + url: http://localhost:8087 + da-admin-service: + url: http://localhost:8088 + da-discovery: + url: http://localhost:8081 \ No newline at end of file