diff --git a/Dockerfile b/Dockerfile index 422e87f..00c5015 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,53 @@ -FROM eclipse-temurin:21-jdk-alpine AS builder -WORKDIR /app -COPY pom.xml . -COPY src ./src -RUN apk add --no-cache maven && mvn clean package -DskipTests +# ============================================================ +# Stage 1: Build +# ============================================================ +FROM eclipse-temurin:21-jdk-alpine AS build -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app +# Install Maven +RUN apk add --no-cache maven + +WORKDIR /workspace + +# Copy POM first to cache dependency layer +COPY backend/pom.xml . + +# Download dependencies (cached unless pom.xml changes) +RUN mvn dependency:go-offline -B --no-transfer-progress + +# Copy source code and build the fat JAR, skipping tests +COPY backend/src src +RUN mvn package -DskipTests -B --no-transfer-progress + +# ============================================================ +# Stage 2: Runtime +# ============================================================ +FROM eclipse-temurin:21-jre-alpine AS runtime + +# Create a non-root user for security RUN addgroup -S appgroup && adduser -S appuser -G appgroup -COPY --from=builder /app/target/*.jar app.jar -RUN chown appuser:appgroup app.jar + +WORKDIR /app + +# Copy the built JAR from the build stage +COPY --from=build /workspace/target/*.jar app.jar + +# Ensure the app directory is owned by the non-root user +RUN chown -R appuser:appgroup /app + USER appuser + +# Expose application port and management port EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +EXPOSE 8081 + +# Health check – relies on Spring Actuator management port +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget -qO- http://localhost:8081/actuator/health || exit 1 + +# JVM tuning flags for containers (respects cgroup memory limits) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+ExitOnOutOfMemoryError \ + -Djava.security.egd=file:/dev/./urandom" + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"] diff --git a/Dockerfile.orig b/Dockerfile.orig new file mode 100644 index 0000000..06d3ddf --- /dev/null +++ b/Dockerfile.orig @@ -0,0 +1,52 @@ +# ============================================================ +# Stage 1: Build +# ============================================================ +FROM eclipse-temurin:21-jdk-alpine AS build + +# Install Maven +RUN apk add --no-cache maven + +WORKDIR /workspace + +# Copy POM first to cache dependency layer +COPY backend/pom.xml . + +# Download dependencies (cached unless pom.xml changes) +RUN mvn dependency:go-offline -B --no-transfer-progress + +# Copy source code and build the fat JAR, skipping tests +COPY backend/src src +RUN mvn package -DskipTests -B --no-transfer-progress + +# ============================================================ +# Stage 2: Runtime +# ============================================================ +FROM eclipse-temurin:21-jre-alpine AS runtime + +# Create a non-root user for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# Copy the built JAR from the build stage +COPY --from=build /workspace/target/*.jar app.jar + +# Ensure the app directory is owned by the non-root user +RUN chown -R appuser:appgroup /app + +USER appuser + +# Expose application port +EXPOSE 8080 + +# Health check – relies on Spring Actuator +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget -qO- http://localhost:8080/actuator/health || exit 1 + +# JVM tuning flags for containers (respects cgroup memory limits) +ENV JAVA_OPTS="-XX:+UseContainerSupport \ + -XX:MaxRAMPercentage=75.0 \ + -XX:+ExitOnOutOfMemoryError \ + -Djava.security.egd=file:/dev/./urandom" + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..34457c9 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,52 @@ +version: "3.9" + +# Production overrides – extend the base docker-compose.yml with: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: + + # ── Spring Boot application ─────────────────────────────── + app: + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + JWT_ACCESS_TOKEN_EXPIRATION_MS: ${JWT_ACCESS_TOKEN_EXPIRATION_MS:-900000} + JWT_REFRESH_TOKEN_EXPIRATION_MS: ${JWT_REFRESH_TOKEN_EXPIRATION_MS:-604800000} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + JAVA_OPTS: >- + -XX:+UseContainerSupport + -XX:MaxRAMPercentage=75.0 + -XX:+ExitOnOutOfMemoryError + -Djava.security.egd=file:/dev/./urandom + restart: always + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + + # ── MariaDB ─────────────────────────────────────────────── + mariadb: + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME:-jobtracker} + MARIADB_USER: ${SPRING_DATASOURCE_USERNAME} + MARIADB_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + ports: [] # do not expose DB port in production + restart: always + + # ── Prometheus ──────────────────────────────────────────── + prometheus: + ports: [] # do not expose Prometheus externally in production + restart: always + + # ── Grafana ─────────────────────────────────────────────── + grafana: + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3000} + restart: always diff --git a/docker-compose.yml b/docker-compose.yml index bdf6d43..037aefc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,44 @@ -version: '3.8' +version: "3.9" services: - db: - image: mariadb:11.2 - container_name: jobtracker-db + + # ── Spring Boot application ─────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + container_name: job-tracker-app + ports: + - "8080:8080" environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: jobtracker - MYSQL_USER: jobtracker - MYSQL_PASSWORD: jobtracker + SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + # WARNING: Replace this secret before any production use. This value is for development only. + JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment + CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8081/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - job-tracker-net + restart: unless-stopped + + # ── MariaDB ─────────────────────────────────────────────── + mariadb: + image: mariadb:11 + container_name: job-tracker-mariadb ports: - "3306:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: jobtracker volumes: - mariadb_data:/var/lib/mysql healthcheck: @@ -18,24 +46,61 @@ services: interval: 10s timeout: 5s retries: 5 + start_period: 30s + networks: + - job-tracker-net + restart: unless-stopped - app: - build: . - container_name: jobtracker-app + # ── Prometheus ──────────────────────────────────────────── + prometheus: + image: prom/prometheus:v2.54.1 + container_name: job-tracker-prometheus ports: - - "8080:8080" + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - job-tracker-net + restart: unless-stopped + + # ── Grafana ─────────────────────────────────────────────── + grafana: + image: grafana/grafana:10.4.8 + container_name: job-tracker-grafana + ports: + - "3000:3000" environment: - DB_URL: jdbc:mariadb://db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true - DB_USERNAME: jobtracker - DB_PASSWORD: jobtracker - JWT_SECRET: ${JWT_SECRET:-ChangeThisToASecureRandomSecretKeyInProductionAtLeast256BitsLong} - JWT_ACCESS_TOKEN_EXPIRATION_MS: 900000 - JWT_REFRESH_TOKEN_EXPIRATION_MS: 604800000 - CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro depends_on: - db: + prometheus: condition: service_healthy + networks: + - job-tracker-net restart: unless-stopped volumes: mariadb_data: + prometheus_data: + grafana_data: + +networks: + job-tracker-net: + driver: bridge diff --git a/docker-compose.yml.orig b/docker-compose.yml.orig new file mode 100644 index 0000000..5600e15 --- /dev/null +++ b/docker-compose.yml.orig @@ -0,0 +1,99 @@ +version: "3.9" + +services: + + # ── Spring Boot application ─────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + container_name: job-tracker-app + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + # WARNING: Replace this secret before any production use. This value is for development only. + JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment + CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - job-tracker-net + restart: unless-stopped + + # ── MariaDB ─────────────────────────────────────────────── + mariadb: + image: mariadb:11 + container_name: job-tracker-mariadb + ports: + - "3306:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: jobtracker + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - job-tracker-net + restart: unless-stopped + + # ── Prometheus ──────────────────────────────────────────── + prometheus: + image: prom/prometheus:latest + container_name: job-tracker-prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + networks: + - job-tracker-net + restart: unless-stopped + + # ── Grafana ─────────────────────────────────────────────── + grafana: + image: grafana/grafana:latest + container_name: job-tracker-grafana + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + depends_on: + - prometheus + networks: + - job-tracker-net + restart: unless-stopped + +volumes: + mariadb_data: + prometheus_data: + grafana_data: + +networks: + job-tracker-net: + driver: bridge diff --git a/docker-compose_BACKUP_20088.yml b/docker-compose_BACKUP_20088.yml new file mode 100644 index 0000000..5600e15 --- /dev/null +++ b/docker-compose_BACKUP_20088.yml @@ -0,0 +1,99 @@ +version: "3.9" + +services: + + # ── Spring Boot application ─────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + container_name: job-tracker-app + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + # WARNING: Replace this secret before any production use. This value is for development only. + JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment + CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - job-tracker-net + restart: unless-stopped + + # ── MariaDB ─────────────────────────────────────────────── + mariadb: + image: mariadb:11 + container_name: job-tracker-mariadb + ports: + - "3306:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: jobtracker + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - job-tracker-net + restart: unless-stopped + + # ── Prometheus ──────────────────────────────────────────── + prometheus: + image: prom/prometheus:latest + container_name: job-tracker-prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + networks: + - job-tracker-net + restart: unless-stopped + + # ── Grafana ─────────────────────────────────────────────── + grafana: + image: grafana/grafana:latest + container_name: job-tracker-grafana + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + depends_on: + - prometheus + networks: + - job-tracker-net + restart: unless-stopped + +volumes: + mariadb_data: + prometheus_data: + grafana_data: + +networks: + job-tracker-net: + driver: bridge diff --git a/docker-compose_BASE_20088.yml b/docker-compose_BASE_20088.yml new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose_LOCAL_20088.yml b/docker-compose_LOCAL_20088.yml new file mode 100644 index 0000000..5600e15 --- /dev/null +++ b/docker-compose_LOCAL_20088.yml @@ -0,0 +1,99 @@ +version: "3.9" + +services: + + # ── Spring Boot application ─────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + container_name: job-tracker-app + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: root + # WARNING: Replace this secret before any production use. This value is for development only. + JWT_SECRET: ChangeThisToAVeryLongSecretKeyForJWTTokensInDevelopment + CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://localhost:5173" + depends_on: + mariadb: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - job-tracker-net + restart: unless-stopped + + # ── MariaDB ─────────────────────────────────────────────── + mariadb: + image: mariadb:11 + container_name: job-tracker-mariadb + ports: + - "3306:3306" + environment: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: jobtracker + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - job-tracker-net + restart: unless-stopped + + # ── Prometheus ──────────────────────────────────────────── + prometheus: + image: prom/prometheus:latest + container_name: job-tracker-prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + networks: + - job-tracker-net + restart: unless-stopped + + # ── Grafana ─────────────────────────────────────────────── + grafana: + image: grafana/grafana:latest + container_name: job-tracker-grafana + ports: + - "3000:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/etc/grafana/dashboards:ro + depends_on: + - prometheus + networks: + - job-tracker-net + restart: unless-stopped + +volumes: + mariadb_data: + prometheus_data: + grafana_data: + +networks: + job-tracker-net: + driver: bridge diff --git a/docker-compose_REMOTE_20088.yml b/docker-compose_REMOTE_20088.yml new file mode 100644 index 0000000..bdf6d43 --- /dev/null +++ b/docker-compose_REMOTE_20088.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + db: + image: mariadb:11.2 + container_name: jobtracker-db + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: jobtracker + MYSQL_USER: jobtracker + MYSQL_PASSWORD: jobtracker + ports: + - "3306:3306" + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + container_name: jobtracker-app + ports: + - "8080:8080" + environment: + DB_URL: jdbc:mariadb://db:3306/jobtracker?createDatabaseIfNotExist=true&characterEncoding=UTF-8&useUnicode=true + DB_USERNAME: jobtracker + DB_PASSWORD: jobtracker + JWT_SECRET: ${JWT_SECRET:-ChangeThisToASecureRandomSecretKeyInProductionAtLeast256BitsLong} + JWT_ACCESS_TOKEN_EXPIRATION_MS: 900000 + JWT_REFRESH_TOKEN_EXPIRATION_MS: 604800000 + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + depends_on: + db: + condition: service_healthy + restart: unless-stopped + +volumes: + mariadb_data: diff --git a/grafana/dashboards/job-tracker-dashboard.json b/grafana/dashboards/job-tracker-dashboard.json new file mode 100644 index 0000000..9a302e7 --- /dev/null +++ b/grafana/dashboards/job-tracker-dashboard.json @@ -0,0 +1,82 @@ +{ + "title": "Job Apply Tracker", + "uid": "job-tracker-overview", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "HTTP Request Rate (req/s)", + "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"job-tracker\"}[1m])) by (uri, method, status)", + "legendFormat": "{{method}} {{uri}} {{status}}" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "HTTP Response Time (p99 ms)", + "gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application=\"job-tracker\"}[1m])) by (le, uri)) * 1000", + "legendFormat": "p99 {{uri}}" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "HTTP Error Rate (4xx + 5xx)", + "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"job-tracker\", status=~\"4..|5..\"}[1m])) by (status)", + "legendFormat": "HTTP {{status}}" + } + ] + }, + { + "id": 4, + "type": "timeseries", + "title": "JVM Memory Used (MB)", + "gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"job-tracker\"} / 1048576", + "legendFormat": "{{area}} {{id}}" + } + ] + }, + { + "id": 5, + "type": "timeseries", + "title": "CPU Usage", + "gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "process_cpu_usage{application=\"job-tracker\"}", + "legendFormat": "CPU usage" + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "Active DB Connections", + "gridPos": { "x": 12, "y": 16, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "hikaricp_connections_active{application=\"job-tracker\"}", + "legendFormat": "Active connections" + } + ] + } + ] +} diff --git a/grafana/provisioning/dashboards/dashboard.yml b/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..f588ca9 --- /dev/null +++ b/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /etc/grafana/dashboards diff --git a/grafana/provisioning/datasources/datasource.yml b/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..1a57b69 --- /dev/null +++ b/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/pom.xml b/pom.xml index c24b353..cb7c049 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,25 @@ 2.5.0 + + + org.springframework.boot + spring-boot-starter-actuator + + + + + io.micrometer + micrometer-registry-prometheus + + + + + net.logstash.logback + logstash-logback-encoder + 7.4 + + org.springframework.boot diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..7d5765e --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'spring-app' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['app:8081'] diff --git a/src/main/java/com/jobtracker/config/OpenApiConfig.java b/src/main/java/com/jobtracker/config/OpenApiConfig.java index eeeb5a5..3d8b1c1 100644 --- a/src/main/java/com/jobtracker/config/OpenApiConfig.java +++ b/src/main/java/com/jobtracker/config/OpenApiConfig.java @@ -16,8 +16,8 @@ public OpenAPI openAPI() { final String securitySchemeName = "bearerAuth"; return new OpenAPI() .info(new Info() - .title("Job Tracker API") - .description("REST API for Job Application Tracker PWA") + .title("Job Apply Tracker API") + .description("API for tracking job applications") .version("1.0.0")) .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .components(new Components() diff --git a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java new file mode 100644 index 0000000..bedc5d1 --- /dev/null +++ b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java @@ -0,0 +1,54 @@ +package com.jobtracker.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + // Skip logging for actuator endpoints to avoid log spam from health probes and Prometheus scrapes + return request.getRequestURI().startsWith("/actuator"); + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + try { + filterChain.doFilter(request, response); + } finally { + long duration = System.currentTimeMillis() - start; + String userEmail = resolveUserEmail(); + log.info("method={} path={} status={} duration={}ms userEmail={}", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + duration, + userEmail); + } + } + + private String resolveUserEmail() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + return auth.getName(); + } + return "anonymous"; + } +} diff --git a/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig new file mode 100644 index 0000000..d270a3a --- /dev/null +++ b/src/main/java/com/jobtracker/config/RequestLoggingFilter.java.orig @@ -0,0 +1,48 @@ +package com.jobtracker.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class RequestLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + try { + filterChain.doFilter(request, response); + } finally { + long duration = System.currentTimeMillis() - start; + String userId = resolveUserId(); + log.info("method={} path={} status={} duration={}ms userId={}", + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + duration, + userId); + } + } + + private String resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + return auth.getName(); + } + return "anonymous"; + } +} diff --git a/src/main/java/com/jobtracker/config/SecurityConfig.java b/src/main/java/com/jobtracker/config/SecurityConfig.java index f8d813d..77ea59b 100644 --- a/src/main/java/com/jobtracker/config/SecurityConfig.java +++ b/src/main/java/com/jobtracker/config/SecurityConfig.java @@ -18,10 +18,13 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CorsConfig corsConfig; + private final RequestLoggingFilter requestLoggingFilter; - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig) { + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CorsConfig corsConfig, + RequestLoggingFilter requestLoggingFilter) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.corsConfig = corsConfig; + this.requestLoggingFilter = requestLoggingFilter; } @Bean @@ -40,7 +43,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(requestLoggingFilter, JwtAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/jobtracker/controller/ApplicationController.java b/src/main/java/com/jobtracker/controller/ApplicationController.java index 1022ef0..dd8531c 100644 --- a/src/main/java/com/jobtracker/controller/ApplicationController.java +++ b/src/main/java/com/jobtracker/controller/ApplicationController.java @@ -2,6 +2,12 @@ import com.jobtracker.dto.application.*; import com.jobtracker.service.ApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpStatus; @@ -12,6 +18,7 @@ import java.util.List; import java.util.Map; +@Tag(name = "Applications", description = "Job application management endpoints") @RestController @RequestMapping("/api/applications") public class ApplicationController { @@ -22,60 +29,140 @@ public ApplicationController(ApplicationService applicationService) { this.applicationService = applicationService; } + @Operation( + summary = "Create a job application", + description = "Creates a new job application for the authenticated user", + responses = { + @ApiResponse(responseCode = "201", description = "Application created", + content = @Content(schema = @Schema(implementation = ApplicationResponse.class))), + @ApiResponse(responseCode = "400", description = "Validation error") + } + ) @PostMapping public ResponseEntity create(@Valid @RequestBody ApplicationRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(applicationService.create(request)); } + @Operation( + summary = "Get application by ID", + description = "Returns a single job application owned by the authenticated user", + responses = { + @ApiResponse(responseCode = "200", description = "Application found", + content = @Content(schema = @Schema(implementation = ApplicationResponse.class))), + @ApiResponse(responseCode = "404", description = "Application not found") + } + ) @GetMapping("/{id}") - public ResponseEntity getById(@PathVariable Long id) { + public ResponseEntity getById( + @Parameter(description = "Application ID", required = true) @PathVariable Long id) { return ResponseEntity.ok(applicationService.getById(id)); } + @Operation( + summary = "Update a job application", + description = "Replaces all fields of an existing job application", + responses = { + @ApiResponse(responseCode = "200", description = "Application updated", + content = @Content(schema = @Schema(implementation = ApplicationResponse.class))), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Application not found") + } + ) @PutMapping("/{id}") - public ResponseEntity update(@PathVariable Long id, - @Valid @RequestBody ApplicationRequest request) { + public ResponseEntity update( + @Parameter(description = "Application ID", required = true) @PathVariable Long id, + @Valid @RequestBody ApplicationRequest request) { return ResponseEntity.ok(applicationService.update(id, request)); } + @Operation( + summary = "Update application status", + description = "Partially updates only the status field of an application", + responses = { + @ApiResponse(responseCode = "200", description = "Status updated", + content = @Content(schema = @Schema(implementation = ApplicationResponse.class))), + @ApiResponse(responseCode = "404", description = "Application not found") + } + ) @PatchMapping("/{id}/status") - public ResponseEntity updateStatus(@PathVariable Long id, - @Valid @RequestBody UpdateStatusRequest request) { + public ResponseEntity updateStatus( + @Parameter(description = "Application ID", required = true) @PathVariable Long id, + @Valid @RequestBody UpdateStatusRequest request) { return ResponseEntity.ok(applicationService.updateStatus(id, request)); } + @Operation( + summary = "Update recruiter DM reminder", + description = "Enables or disables the recruiter DM reminder for an application", + responses = { + @ApiResponse(responseCode = "200", description = "Reminder updated", + content = @Content(schema = @Schema(implementation = ApplicationResponse.class))), + @ApiResponse(responseCode = "404", description = "Application not found") + } + ) @PatchMapping("/{id}/reminder") - public ResponseEntity updateReminder(@PathVariable Long id, - @Valid @RequestBody UpdateReminderRequest request) { + public ResponseEntity updateReminder( + @Parameter(description = "Application ID", required = true) @PathVariable Long id, + @Valid @RequestBody UpdateReminderRequest request) { return ResponseEntity.ok(applicationService.updateReminder(id, request)); } + @Operation( + summary = "Delete a job application", + responses = { + @ApiResponse(responseCode = "200", description = "Application deleted"), + @ApiResponse(responseCode = "404", description = "Application not found") + } + ) @DeleteMapping("/{id}") - public ResponseEntity> delete(@PathVariable Long id) { + public ResponseEntity> delete( + @Parameter(description = "Application ID", required = true) @PathVariable Long id) { applicationService.delete(id); return ResponseEntity.ok(Map.of("message", "Application deleted successfully")); } + @Operation( + summary = "List job applications", + description = "Returns a paginated, filterable list of job applications for the authenticated user", + responses = { + @ApiResponse(responseCode = "200", description = "Page of applications", + content = @Content(schema = @Schema(implementation = ApplicationPageResponse.class))) + } + ) @GetMapping public ResponseEntity getAll( - @RequestParam(required = false) String status, - @RequestParam(required = false) String recruiterName, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateFrom, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateTo, - @RequestParam(required = false) Boolean interviewScheduled, - @RequestParam(required = false) Boolean recruiterDmReminderEnabled, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(required = false) String sort) { + @Parameter(description = "Filter by status") @RequestParam(required = false) String status, + @Parameter(description = "Filter by recruiter name") @RequestParam(required = false) String recruiterName, + @Parameter(description = "Filter from date (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateFrom, + @Parameter(description = "Filter to date (yyyy-MM-dd)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate applicationDateTo, + @Parameter(description = "Filter by interview scheduled flag") @RequestParam(required = false) Boolean interviewScheduled, + @Parameter(description = "Filter by recruiter DM reminder flag") @RequestParam(required = false) Boolean recruiterDmReminderEnabled, + @Parameter(description = "Page number (0-based)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "Page size") @RequestParam(defaultValue = "10") int size, + @Parameter(description = "Sort field") @RequestParam(required = false) String sort) { return ResponseEntity.ok(applicationService.getAll(status, recruiterName, applicationDateFrom, applicationDateTo, interviewScheduled, recruiterDmReminderEnabled, page, size, sort)); } + @Operation( + summary = "Get upcoming applications", + description = "Returns applications with upcoming next-step dates", + responses = { + @ApiResponse(responseCode = "200", description = "List of upcoming applications") + } + ) @GetMapping("/upcoming") public ResponseEntity> getUpcoming() { return ResponseEntity.ok(applicationService.getUpcoming()); } + @Operation( + summary = "Get overdue applications", + description = "Returns applications whose next-step date has already passed", + responses = { + @ApiResponse(responseCode = "200", description = "List of overdue applications") + } + ) @GetMapping("/overdue") public ResponseEntity> getOverdue() { return ResponseEntity.ok(applicationService.getOverdue()); diff --git a/src/main/java/com/jobtracker/controller/AuthController.java b/src/main/java/com/jobtracker/controller/AuthController.java index 6c6fc9b..406f589 100644 --- a/src/main/java/com/jobtracker/controller/AuthController.java +++ b/src/main/java/com/jobtracker/controller/AuthController.java @@ -4,11 +4,17 @@ import com.jobtracker.mapper.AuthMapper; import com.jobtracker.service.AuthService; import com.jobtracker.util.SecurityUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +@Tag(name = "Auth", description = "Authentication and user management endpoints") @RestController @RequestMapping("/api/auth") public class AuthController { @@ -23,36 +29,98 @@ public AuthController(AuthService authService, AuthMapper authMapper, SecurityUt this.securityUtils = securityUtils; } + @Operation( + summary = "Register a new user", + description = "Creates a new user account and returns access and refresh tokens", + responses = { + @ApiResponse(responseCode = "201", description = "User registered successfully", + content = @Content(schema = @Schema(implementation = AuthResponse.class))), + @ApiResponse(responseCode = "400", description = "Validation error or passwords do not match"), + @ApiResponse(responseCode = "409", description = "Email already in use") + } + ) @PostMapping("/register") public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request)); } + @Operation( + summary = "Login", + description = "Authenticates a user and returns access and refresh tokens", + responses = { + @ApiResponse(responseCode = "200", description = "Login successful", + content = @Content(schema = @Schema(implementation = AuthResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid credentials") + } + ) @PostMapping("/login") public ResponseEntity login(@Valid @RequestBody LoginRequest request) { return ResponseEntity.ok(authService.login(request)); } + @Operation( + summary = "Refresh access token", + description = "Issues a new access token using a valid refresh token", + responses = { + @ApiResponse(responseCode = "200", description = "Token refreshed", + content = @Content(schema = @Schema(implementation = RefreshResponse.class))), + @ApiResponse(responseCode = "401", description = "Invalid or expired refresh token") + } + ) @PostMapping("/refresh") public ResponseEntity refresh(@Valid @RequestBody RefreshTokenRequest request) { return ResponseEntity.ok(authService.refresh(request)); } + @Operation( + summary = "Request password reset", + description = "Sends a password reset token to the provided email address", + responses = { + @ApiResponse(responseCode = "200", description = "Reset email sent", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + } + ) @PostMapping("/forgot-password") public ResponseEntity forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { return ResponseEntity.ok(authService.forgotPassword(request)); } + @Operation( + summary = "Reset password", + description = "Resets the user password using a valid reset token", + responses = { + @ApiResponse(responseCode = "200", description = "Password reset successful", + content = @Content(schema = @Schema(implementation = MessageResponse.class))), + @ApiResponse(responseCode = "400", description = "Invalid or expired reset token") + } + ) @PostMapping("/reset-password") public ResponseEntity resetPassword(@Valid @RequestBody ResetPasswordRequest request) { return ResponseEntity.ok(authService.resetPassword(request)); } + @Operation( + summary = "Logout", + description = "Invalidates the provided refresh token", + responses = { + @ApiResponse(responseCode = "200", description = "Logged out successfully", + content = @Content(schema = @Schema(implementation = MessageResponse.class))) + } + ) @PostMapping("/logout") public ResponseEntity logout(@Valid @RequestBody LogoutRequest request) { return ResponseEntity.ok(authService.logout(request)); } + @Operation( + summary = "Get current user", + description = "Returns the currently authenticated user's profile", + responses = { + @ApiResponse(responseCode = "200", description = "Current user details", + content = @Content(schema = @Schema(implementation = UserResponse.class))), + @ApiResponse(responseCode = "401", description = "Not authenticated") + } + ) @GetMapping("/me") public ResponseEntity me() { return ResponseEntity.ok(authMapper.toUserResponse(securityUtils.getCurrentUser())); diff --git a/src/main/java/com/jobtracker/controller/DashboardController.java b/src/main/java/com/jobtracker/controller/DashboardController.java index e2272d4..736b11a 100644 --- a/src/main/java/com/jobtracker/controller/DashboardController.java +++ b/src/main/java/com/jobtracker/controller/DashboardController.java @@ -2,11 +2,17 @@ import com.jobtracker.dto.dashboard.DashboardSummaryResponse; import com.jobtracker.service.DashboardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Dashboard", description = "Dashboard summary statistics endpoints") @RestController @RequestMapping("/api/dashboard") public class DashboardController { @@ -17,6 +23,15 @@ public DashboardController(DashboardService dashboardService) { this.dashboardService = dashboardService; } + @Operation( + summary = "Get dashboard summary", + description = "Returns aggregate statistics for the authenticated user's job applications", + responses = { + @ApiResponse(responseCode = "200", description = "Dashboard summary", + content = @Content(schema = @Schema(implementation = DashboardSummaryResponse.class))), + @ApiResponse(responseCode = "401", description = "Not authenticated") + } + ) @GetMapping("/summary") public ResponseEntity getSummary() { return ResponseEntity.ok(dashboardService.getSummary()); diff --git a/src/main/java/com/jobtracker/dto/application/ApplicationPageResponse.java b/src/main/java/com/jobtracker/dto/application/ApplicationPageResponse.java index 996ee26..8bf404a 100644 --- a/src/main/java/com/jobtracker/dto/application/ApplicationPageResponse.java +++ b/src/main/java/com/jobtracker/dto/application/ApplicationPageResponse.java @@ -1,11 +1,19 @@ package com.jobtracker.dto.application; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.List; +@Schema(description = "Paginated list of job applications") public record ApplicationPageResponse( + @Schema(description = "Applications on this page") List content, + @Schema(description = "Current page number (0-based)", example = "0") int pageNumber, + @Schema(description = "Number of items per page", example = "10") int pageSize, + @Schema(description = "Total number of applications matching the filter", example = "42") long totalElements, + @Schema(description = "Total number of pages", example = "5") int totalPages ) {} diff --git a/src/main/java/com/jobtracker/dto/application/ApplicationRequest.java b/src/main/java/com/jobtracker/dto/application/ApplicationRequest.java index 63ec0dd..d0f0537 100644 --- a/src/main/java/com/jobtracker/dto/application/ApplicationRequest.java +++ b/src/main/java/com/jobtracker/dto/application/ApplicationRequest.java @@ -1,6 +1,7 @@ package com.jobtracker.dto.application; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PastOrPresent; @@ -9,35 +10,47 @@ import java.time.LocalDate; import java.time.LocalDateTime; +@Schema(description = "Request payload for creating or updating a job application") public record ApplicationRequest( + @Schema(description = "Job title or vacancy name", example = "Backend Engineer") @NotBlank(message = "Vacancy name is required") String vacancyName, + @Schema(description = "Name of the recruiter (optional)", example = "Jane Smith") String recruiterName, + @Schema(description = "Who posted or opened the vacancy", example = "TechCorp HR") @NotBlank(message = "Vacancy opened by is required") String vacancyOpenedBy, + @Schema(description = "URL link to the vacancy posting", example = "https://example.com/jobs/123") @Pattern(regexp = "^(https?|ftp)://.*", message = "Vacancy link must be a valid URL") String vacancyLink, + @Schema(description = "Date the application was submitted (yyyy-MM-dd)", example = "2024-06-01") @NotNull(message = "Application date is required") @PastOrPresent(message = "Application date cannot be in the future") @JsonFormat(pattern = "yyyy-MM-dd") LocalDate applicationDate, + @Schema(description = "Whether the recruiter accepted a LinkedIn connection", example = "true") @NotNull(message = "rhAcceptedConnection is required") Boolean rhAcceptedConnection, + @Schema(description = "Whether an interview has been scheduled", example = "false") @NotNull(message = "interviewScheduled is required") Boolean interviewScheduled, + @Schema(description = "Date/time of the next step (yyyy-MM-dd'T'HH:mm:ss)", example = "2024-06-10T14:00:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime nextStepDateTime, + @Schema(description = "Application status", example = "APPLIED", + allowableValues = {"APPLIED", "IN_REVIEW", "INTERVIEW", "OFFER", "REJECTED", "WITHDRAWN"}) @NotBlank(message = "Status is required") String status, + @Schema(description = "Whether a DM reminder to the recruiter is enabled", example = "true") @NotNull(message = "recruiterDmReminderEnabled is required") Boolean recruiterDmReminderEnabled ) {} diff --git a/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java b/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java index e2cc703..9833b21 100644 --- a/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java +++ b/src/main/java/com/jobtracker/dto/application/ApplicationResponse.java @@ -1,26 +1,41 @@ package com.jobtracker.dto.application; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.time.LocalDateTime; +@Schema(description = "Job application details") public record ApplicationResponse( + @Schema(description = "Unique application ID", example = "1") Long id, + @Schema(description = "Job title or vacancy name", example = "Backend Engineer") String vacancyName, + @Schema(description = "Recruiter name", example = "Jane Smith") String recruiterName, + @Schema(description = "Who opened the vacancy", example = "TechCorp HR") String vacancyOpenedBy, + @Schema(description = "URL to the vacancy posting", example = "https://example.com/jobs/123") String vacancyLink, + @Schema(description = "Application date (yyyy-MM-dd)", example = "2024-06-01") @JsonFormat(pattern = "yyyy-MM-dd") LocalDate applicationDate, + @Schema(description = "Whether the recruiter accepted a LinkedIn connection", example = "true") boolean rhAcceptedConnection, + @Schema(description = "Whether an interview has been scheduled", example = "false") boolean interviewScheduled, + @Schema(description = "Next step date/time", example = "2024-06-10T14:00:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime nextStepDateTime, + @Schema(description = "Application status", example = "APPLIED") String status, + @Schema(description = "Whether a recruiter DM reminder is enabled", example = "true") boolean recruiterDmReminderEnabled, + @Schema(description = "Record creation timestamp", example = "2024-06-01T10:00:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime createdAt, + @Schema(description = "Last update timestamp", example = "2024-06-05T12:30:00") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime updatedAt ) {} diff --git a/src/main/java/com/jobtracker/dto/application/UpdateReminderRequest.java b/src/main/java/com/jobtracker/dto/application/UpdateReminderRequest.java index f0605e2..8933aae 100644 --- a/src/main/java/com/jobtracker/dto/application/UpdateReminderRequest.java +++ b/src/main/java/com/jobtracker/dto/application/UpdateReminderRequest.java @@ -1,8 +1,11 @@ package com.jobtracker.dto.application; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema(description = "Request to toggle the recruiter DM reminder on an application") public record UpdateReminderRequest( + @Schema(description = "Whether the recruiter DM reminder should be enabled", example = "true") @NotNull(message = "recruiterDmReminderEnabled is required") Boolean recruiterDmReminderEnabled ) {} diff --git a/src/main/java/com/jobtracker/dto/application/UpdateStatusRequest.java b/src/main/java/com/jobtracker/dto/application/UpdateStatusRequest.java index 25b1c99..aabc80e 100644 --- a/src/main/java/com/jobtracker/dto/application/UpdateStatusRequest.java +++ b/src/main/java/com/jobtracker/dto/application/UpdateStatusRequest.java @@ -1,8 +1,12 @@ package com.jobtracker.dto.application; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "Request to update the status of a job application") public record UpdateStatusRequest( + @Schema(description = "New application status", example = "INTERVIEW", + allowableValues = {"APPLIED", "IN_REVIEW", "INTERVIEW", "OFFER", "REJECTED", "WITHDRAWN"}) @NotBlank(message = "Status is required") String status ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/AuthResponse.java b/src/main/java/com/jobtracker/dto/auth/AuthResponse.java index 290b19c..1a87eec 100644 --- a/src/main/java/com/jobtracker/dto/auth/AuthResponse.java +++ b/src/main/java/com/jobtracker/dto/auth/AuthResponse.java @@ -1,7 +1,13 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Authentication response containing tokens and user info") public record AuthResponse( + @Schema(description = "JWT access token", example = "eyJhbGciOiJIUzI1NiJ9...") String accessToken, + @Schema(description = "Refresh token for obtaining new access tokens", example = "dGhpcyBpcyBhIHJlZnJlc2g...") String refreshToken, + @Schema(description = "Authenticated user details") UserResponse user ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/ForgotPasswordRequest.java b/src/main/java/com/jobtracker/dto/auth/ForgotPasswordRequest.java index d919665..3cfc109 100644 --- a/src/main/java/com/jobtracker/dto/auth/ForgotPasswordRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/ForgotPasswordRequest.java @@ -1,9 +1,12 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +@Schema(description = "Forgot password request") public record ForgotPasswordRequest( + @Schema(description = "Email address associated with the account", example = "john@example.com") @NotBlank(message = "Email is required") @Email(message = "Invalid email format") String email diff --git a/src/main/java/com/jobtracker/dto/auth/LoginRequest.java b/src/main/java/com/jobtracker/dto/auth/LoginRequest.java index 7934793..aeab9f3 100644 --- a/src/main/java/com/jobtracker/dto/auth/LoginRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/LoginRequest.java @@ -1,13 +1,17 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +@Schema(description = "Login request") public record LoginRequest( + @Schema(description = "User email address", example = "john@example.com") @NotBlank(message = "Email is required") @Email(message = "Invalid email format") String email, + @Schema(description = "User password", example = "secureP@ss1") @NotBlank(message = "Password is required") String password ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/LogoutRequest.java b/src/main/java/com/jobtracker/dto/auth/LogoutRequest.java index a5f79b7..03afeb6 100644 --- a/src/main/java/com/jobtracker/dto/auth/LogoutRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/LogoutRequest.java @@ -1,8 +1,11 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "Logout request") public record LogoutRequest( + @Schema(description = "Refresh token to be invalidated", example = "dGhpcyBpcyBhIHJlZnJlc2g...") @NotBlank(message = "Refresh token is required") String refreshToken ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/MessageResponse.java b/src/main/java/com/jobtracker/dto/auth/MessageResponse.java index 5aa4f9d..eda09c0 100644 --- a/src/main/java/com/jobtracker/dto/auth/MessageResponse.java +++ b/src/main/java/com/jobtracker/dto/auth/MessageResponse.java @@ -1,5 +1,9 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Generic message response") public record MessageResponse( + @Schema(description = "Informational message", example = "Operation completed successfully") String message ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/RefreshResponse.java b/src/main/java/com/jobtracker/dto/auth/RefreshResponse.java index ebc30b6..d08f0a0 100644 --- a/src/main/java/com/jobtracker/dto/auth/RefreshResponse.java +++ b/src/main/java/com/jobtracker/dto/auth/RefreshResponse.java @@ -1,6 +1,11 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Refreshed access token response") public record RefreshResponse( + @Schema(description = "New JWT access token", example = "eyJhbGciOiJIUzI1NiJ9...") String accessToken, + @Schema(description = "Refresh token (same or rotated)", example = "dGhpcyBpcyBhIHJlZnJlc2g...") String refreshToken ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/RefreshTokenRequest.java b/src/main/java/com/jobtracker/dto/auth/RefreshTokenRequest.java index a5f9d6d..5527a8f 100644 --- a/src/main/java/com/jobtracker/dto/auth/RefreshTokenRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/RefreshTokenRequest.java @@ -1,8 +1,11 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +@Schema(description = "Token refresh request") public record RefreshTokenRequest( + @Schema(description = "Valid refresh token", example = "dGhpcyBpcyBhIHJlZnJlc2g...") @NotBlank(message = "Refresh token is required") String refreshToken ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java b/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java index 0580d27..19d3ee9 100644 --- a/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/RegisterRequest.java @@ -1,23 +1,29 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +@Schema(description = "User registration request") public record RegisterRequest( + @Schema(description = "User's full name", example = "John Doe") @NotBlank(message = "Name is required") @Size(max = 150, message = "Name must not exceed 150 characters") String name, + @Schema(description = "User email address", example = "john@example.com") @NotBlank(message = "Email is required") @Email(message = "Invalid email format") @Size(max = 255, message = "Email must not exceed 255 characters") String email, + @Schema(description = "Password (minimum 8 characters)", example = "secureP@ss1") @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least 8 characters") String password, + @Schema(description = "Confirm password (must match password)", example = "secureP@ss1") @NotBlank(message = "Confirm password is required") String confirmPassword ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/ResetPasswordRequest.java b/src/main/java/com/jobtracker/dto/auth/ResetPasswordRequest.java index 8b5e3bb..98c5295 100644 --- a/src/main/java/com/jobtracker/dto/auth/ResetPasswordRequest.java +++ b/src/main/java/com/jobtracker/dto/auth/ResetPasswordRequest.java @@ -1,16 +1,21 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +@Schema(description = "Password reset request") public record ResetPasswordRequest( + @Schema(description = "Password reset token received via email", example = "abc123xyz") @NotBlank(message = "Token is required") String token, + @Schema(description = "New password (minimum 8 characters)", example = "newSecureP@ss1") @NotBlank(message = "New password is required") @Size(min = 8, message = "Password must be at least 8 characters") String newPassword, + @Schema(description = "Confirm new password (must match newPassword)", example = "newSecureP@ss1") @NotBlank(message = "Confirm password is required") String confirmPassword ) {} diff --git a/src/main/java/com/jobtracker/dto/auth/UserResponse.java b/src/main/java/com/jobtracker/dto/auth/UserResponse.java index 2e5e688..271016a 100644 --- a/src/main/java/com/jobtracker/dto/auth/UserResponse.java +++ b/src/main/java/com/jobtracker/dto/auth/UserResponse.java @@ -1,7 +1,13 @@ package com.jobtracker.dto.auth; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Authenticated user profile") public record UserResponse( + @Schema(description = "User ID", example = "1") Long id, + @Schema(description = "User display name", example = "John Doe") String name, + @Schema(description = "User email address", example = "john@example.com") String email ) {} diff --git a/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java b/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java index 37a1107..b849762 100644 --- a/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java +++ b/src/main/java/com/jobtracker/dto/dashboard/DashboardSummaryResponse.java @@ -1,9 +1,17 @@ package com.jobtracker.dto.dashboard; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Aggregate dashboard statistics for the authenticated user") public record DashboardSummaryResponse( + @Schema(description = "Total number of job applications", example = "25") long totalApplications, + @Schema(description = "Applications still awaiting a response", example = "10") long waitingResponses, + @Schema(description = "Applications with an interview scheduled", example = "3") long interviewsScheduled, + @Schema(description = "Applications with overdue follow-up dates", example = "2") long overdueFollowUps, + @Schema(description = "Applications with recruiter DM reminder enabled", example = "5") long dmRemindersEnabled ) {} diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java index 094a34f..0d4b520 100644 --- a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package com.jobtracker.exception; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.AuthenticationException; import org.springframework.http.HttpStatus; @@ -16,23 +18,29 @@ @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(ResourceNotFoundException.class) public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { + log.warn("event=RESOURCE_NOT_FOUND message={}", ex.getMessage()); return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage()); } @ExceptionHandler(BadRequestException.class) public ResponseEntity> handleBadRequest(BadRequestException ex) { + log.warn("event=BAD_REQUEST message={}", ex.getMessage()); return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); } @ExceptionHandler(UnauthorizedException.class) public ResponseEntity> handleUnauthorized(UnauthorizedException ex) { + log.warn("event=UNAUTHORIZED message={}", ex.getMessage()); return buildResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); } @ExceptionHandler(ConflictException.class) public ResponseEntity> handleConflict(ConflictException ex) { + log.warn("event=CONFLICT message={}", ex.getMessage()); return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); } @@ -47,6 +55,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotVal for (FieldError error : ex.getBindingResult().getFieldErrors()) { fieldErrors.put(error.getField(), error.getDefaultMessage()); } + log.warn("event=VALIDATION_FAILURE fieldErrors={}", fieldErrors); Map body = new HashMap<>(); body.put("timestamp", LocalDateTime.now().toString()); body.put("status", HttpStatus.BAD_REQUEST.value()); @@ -57,6 +66,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotVal @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneral(Exception ex) { + log.error("event=UNEXPECTED_ERROR message={}", ex.getMessage(), ex); return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); } diff --git a/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig new file mode 100644 index 0000000..0d4b520 --- /dev/null +++ b/src/main/java/com/jobtracker/exception/GlobalExceptionHandler.java.orig @@ -0,0 +1,81 @@ +package com.jobtracker.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { + log.warn("event=RESOURCE_NOT_FOUND message={}", ex.getMessage()); + return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity> handleBadRequest(BadRequestException ex) { + log.warn("event=BAD_REQUEST message={}", ex.getMessage()); + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> handleUnauthorized(UnauthorizedException ex) { + log.warn("event=UNAUTHORIZED message={}", ex.getMessage()); + return buildResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity> handleConflict(ConflictException ex) { + log.warn("event=CONFLICT message={}", ex.getMessage()); + return buildResponse(HttpStatus.CONFLICT, ex.getMessage()); + } + + @ExceptionHandler({BadCredentialsException.class, AuthenticationException.class}) + public ResponseEntity> handleBadCredentials(RuntimeException ex) { + return buildResponse(HttpStatus.UNAUTHORIZED, "Invalid credentials"); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map fieldErrors = new HashMap<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + fieldErrors.put(error.getField(), error.getDefaultMessage()); + } + log.warn("event=VALIDATION_FAILURE fieldErrors={}", fieldErrors); + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", "Validation failed"); + body.put("fieldErrors", fieldErrors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error("event=UNEXPECTED_ERROR message={}", ex.getMessage(), ex); + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); + } + + private ResponseEntity> buildResponse(HttpStatus status, String message) { + Map body = new HashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/jobtracker/service/AuthService.java b/src/main/java/com/jobtracker/service/AuthService.java index 103c707..1f2b298 100644 --- a/src/main/java/com/jobtracker/service/AuthService.java +++ b/src/main/java/com/jobtracker/service/AuthService.java @@ -10,7 +10,11 @@ import com.jobtracker.exception.ResourceNotFoundException; import com.jobtracker.mapper.AuthMapper; import com.jobtracker.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -21,6 +25,8 @@ @Service public class AuthService { + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; @@ -56,19 +62,24 @@ public AuthResponse register(RegisterRequest request) { user.setEmail(request.email()); user.setPasswordHash(passwordEncoder.encode(request.password())); user = userRepository.save(user); - + log.info("event=REGISTRATION_SUCCESS email={} userId={}", user.getEmail(), user.getId()); return buildAuthResponse(user); } @Transactional public AuthResponse login(LoginRequest request) { User user = userRepository.findByEmail(request.email()) - .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); + .orElseThrow(() -> { + log.warn("event=LOGIN_FAILURE reason=USER_NOT_FOUND email={}", request.email()); + return new BadCredentialsException("Invalid credentials"); + }); if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + log.warn("event=LOGIN_FAILURE reason=WRONG_PASSWORD userId={}", user.getId()); throw new BadCredentialsException("Invalid credentials"); } + log.info("event=LOGIN_SUCCESS userId={}", user.getId()); return buildAuthResponse(user); } @@ -116,6 +127,9 @@ public MessageResponse resetPassword(ResetPasswordRequest request) { @Transactional public MessageResponse logout(LogoutRequest request) { refreshTokenService.revokeToken(request.refreshToken()); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String userEmail = (auth != null && auth.isAuthenticated()) ? auth.getName() : "unknown"; + log.info("event=LOGOUT_SUCCESS userEmail={}", userEmail); return new MessageResponse("Logged out successfully"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3b46e74..87b9d96 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -30,8 +30,35 @@ jwt: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} +management: + server: + port: ${MANAGEMENT_PORT:8081} + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: when_authorized + metrics: + tags: + application: ${spring.application.name} + distribution: + percentiles-histogram: + http.server.requests: true + springdoc: api-docs: path: /v3/api-docs swagger-ui: path: /swagger-ui.html + group-configs: + - group: auth + display-name: Auth API + paths-to-match: /api/auth/** + - group: applications + display-name: Application API + paths-to-match: /api/applications/** + - group: dashboard + display-name: Dashboard API + paths-to-match: /api/dashboard/** diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..562f073 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + {"app":"${appName}"} + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/logback-spring.xml.orig b/src/main/resources/logback-spring.xml.orig new file mode 100644 index 0000000..3e3368c --- /dev/null +++ b/src/main/resources/logback-spring.xml.orig @@ -0,0 +1,41 @@ + + + + + + + + + {"timestamp":"%d{yyyy-MM-dd'T'HH:mm:ss.SSSZ}","level":"%level","app":"${appName}","logger":"%logger{36}","thread":"%thread","message":"%msg"}%n + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + +