Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git
.github
.gradle
.idea
build
data
.env
src/main/resources/application.properties
db
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Image tag (latest, 1.0.1, main, ...)
IMAGE_TAG=latest

# Database (used by the bundled postgres container and the app)
# DB_HOST/DB_PORT are set to the postgres container in docker-compose.yml
DB_NAME=operation_point
DB_USERNAME=postgres
DB_PASSWORD=changeme

# Public URL of the application (used for file links)
APP_DOMAIN=https://example.com

# Crawler
CRAWLER_TYROL_TOKEN=your-fastapi-token

# Optional
# JPA_DDL_AUTO=update
# SWAGGER_ENABLED=true
# MAX_FILE_SIZE=32GB
# JAVA_OPTS=-Xmx512m
58 changes: 58 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Build & Publish Docker Image

on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ out/

### Software ###
/src/main/resources/application.properties
/data/*
/data/*
.env
/db/
29 changes: 29 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# --- Build stage ---
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace

COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN chmod +x gradlew && ./gradlew --no-daemon dependencies > /dev/null

COPY src src
RUN ./gradlew --no-daemon bootJar -x test \
&& find build/libs -name '*.jar' ! -name '*-plain.jar' -exec cp {} app.jar \;

# --- Runtime stage ---
FROM eclipse-temurin:21-jre
WORKDIR /app

RUN groupadd --system app && useradd --system --gid app app \
&& mkdir -p /app/data/files /app/config \
&& chown -R app:app /app

COPY --from=build /workspace/app.jar /app/app.jar
# Spring Boot loads ./config/application.properties automatically (overrides classpath config)
COPY docker/application.properties /app/config/application.properties

USER app
EXPOSE 8080
VOLUME ["/app/data"]

ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies {
implementation 'com.opencsv:opencsv:5.12.0'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework:spring-oxm'
implementation 'org.springframework.boot:spring-boot-starter-aspectj'
}

tasks.named('test') {
Expand Down
33 changes: 33 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
operation-point:
image: ghcr.io/jpromi/operation-point:${IMAGE_TAG:-latest}
container_name: operation-point
restart: unless-stopped
env_file: .env
environment:
DB_HOST: postgres
DB_PORT: 5432
ports:
- "8080:8080"
volumes:
- ./data:/app/data
depends_on:
postgres:
condition: service_healthy

postgres:
image: postgres:17-alpine
container_name: operation-point-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-operation_point}
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env}
TZ: UTC
volumes:
- ./db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
32 changes: 32 additions & 0 deletions docker/application.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
spring.application.name=operation-point

# Database
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:operation_point}
spring.datasource.username=${DB_USERNAME:postgres}
spring.datasource.password=${DB_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver

spring.jpa.properties.hibernate.jdbc.time_zone=UTC
spring.thymeleaf.extras.spring-security.enabled=true

# JPA Settings
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:update}

# Swagger Settings
springdoc.swagger-ui.enabled=${SWAGGER_ENABLED:true}
springdoc.api-docs.enabled=${SWAGGER_ENABLED:true}

# Variables for the application
com.jpromi.operation_point.crawler.tyrol.token=${CRAWLER_TYROL_TOKEN:}
com.jpromi.operation_point.domain=${APP_DOMAIN:http://localhost:8080}
com.jpromi.operation_point.file.storage.path=/app/data/files

# Server
server.address=0.0.0.0
server.port=8080
spring.servlet.multipart.max-file-size=${MAX_FILE_SIZE:32GB}
spring.servlet.multipart.max-request-size=${MAX_FILE_SIZE:32GB}

# Tomcat Settings
server.tomcat.max-part-count=-1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.jpromi.operation_point.annotations;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Statistic {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.jpromi.operation_point.annotations;

import com.jpromi.operation_point.service.StatisticService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
@RequiredArgsConstructor
public class StatisticAspect {

private final HttpServletRequest request;

private final StatisticService statisticService;

@Around("@annotation(statistic)")
public Object track(
ProceedingJoinPoint joinPoint,
Statistic statistic
) throws Throwable {
statisticService.setStat();

return joinPoint.proceed();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jpromi.operation_point.controller;

import com.jpromi.operation_point.annotations.Statistic;
import com.jpromi.operation_point.entity.Firedepartment;
import com.jpromi.operation_point.entity.Operation;
import com.jpromi.operation_point.mapper.FiredepartmentResponseMapper;
Expand All @@ -9,6 +10,7 @@
import com.jpromi.operation_point.repository.OperationRepository;
import com.jpromi.operation_point.service.FiredepartmentService;
import com.jpromi.operation_point.service.OperationService;
import com.jpromi.operation_point.service.StatisticService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -33,15 +35,24 @@ public class FiredepartmentController {
private final FiredepartmentResponseMapper firedepartmentResponseMapper;
private final OperationResponseMapper operationResponseMapper;
private final OperationRepository operationRepository;
private final StatisticService statisticService;

@Autowired
public FiredepartmentController(FiredepartmentService firedepartmentService, FiredepartmentResponseMapper firedepartmentResponseMapper, OperationResponseMapper operationResponseMapper, OperationRepository operationRepository) {
public FiredepartmentController(
FiredepartmentService firedepartmentService,
FiredepartmentResponseMapper firedepartmentResponseMapper,
OperationResponseMapper operationResponseMapper,
OperationRepository operationRepository,
StatisticService statisticService
) {
this.firedepartmentService = firedepartmentService;
this.firedepartmentResponseMapper = firedepartmentResponseMapper;
this.operationResponseMapper = operationResponseMapper;
this.operationRepository = operationRepository;
this.statisticService = statisticService;
}

@Statistic
@GetMapping(value = "list", produces = {"application/json"})
public ResponseEntity<Page<FiredepartmentResponse>> getList(
@RequestParam(required = false, value = "q") String query,
Expand Down Expand Up @@ -75,6 +86,7 @@ public ResponseEntity<FiredepartmentResponse> getFiredepartmentByUuid(@PathVaria
if (firedepartment == null) {
return ResponseEntity.notFound().build();
}
statisticService.setStatForDetail("firedepartment", firedepartment.getUuid());
FiredepartmentResponse response = firedepartmentResponseMapper.fromFiredepartment(firedepartment);
return ResponseEntity.ok(response);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.jpromi.operation_point.controller;

import com.jpromi.operation_point.annotations.Statistic;
import com.jpromi.operation_point.entity.Operation;
import com.jpromi.operation_point.mapper.LocationStatisticResponseMapper;
import com.jpromi.operation_point.mapper.OperationResponseMapper;
import com.jpromi.operation_point.model.LocationStatisticResponse;
import com.jpromi.operation_point.model.OperationResponse;
import com.jpromi.operation_point.service.OperationService;
import com.jpromi.operation_point.service.StatisticService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
Expand All @@ -26,14 +28,21 @@ public class OperationController {
private final OperationService operationService;
private final OperationResponseMapper operationResponseMapper;
private final LocationStatisticResponseMapper locationStatisticResponseMapper;
private final StatisticService statisticService;

@Autowired
public OperationController(OperationService operationService, OperationResponseMapper operationResponseMapper, LocationStatisticResponseMapper locationStatisticResponseMapper) {
public OperationController(
OperationService operationService,
OperationResponseMapper operationResponseMapper,
LocationStatisticResponseMapper locationStatisticResponseMapper,
StatisticService statisticService) {
this.operationService = operationService;
this.operationResponseMapper = operationResponseMapper;
this.locationStatisticResponseMapper = locationStatisticResponseMapper;
this.statisticService = statisticService;
}

@Statistic
@GetMapping(value = "list", produces = {"application/json"})
public ResponseEntity<List<OperationResponse>> getList() {
List<Operation> operations = operationService.getActiveOperations();
Expand All @@ -46,6 +55,7 @@ public ResponseEntity<Long> getCount() {
return ResponseEntity.ok(count);
}

@Statistic
@GetMapping(value = "list/statistic", produces = {"application/json"})
public ResponseEntity<List<LocationStatisticResponse>> getStatistic() {
List<Operation> operations = operationService.getActiveOperations();
Expand All @@ -58,6 +68,7 @@ public ResponseEntity<List<LocationStatisticResponse>> getStatistic() {
return ResponseEntity.ok(statisticResponses);
}

@Statistic
@GetMapping(value = "list/{federalState}", produces = {"application/json"})
public ResponseEntity<List<OperationResponse>> getListByFederalState(@PathVariable String federalState, @RequestParam(required = false) String district) {
List<Operation> operations;
Expand All @@ -70,12 +81,14 @@ public ResponseEntity<List<OperationResponse>> getListByFederalState(@PathVariab
return OperationService.BuildResponseFromOperations(operations, operationResponseMapper);
}

@Statistic
@GetMapping(value = "list/{federalState}/count", produces = {"application/json"})
public ResponseEntity<Long> getCountByFederalState(@PathVariable String federalState) {
Long count = operationService.getActiveOperationsByFederalStateCount(federalState);
return ResponseEntity.ok(count);
}

@Statistic
@GetMapping(value = "list/{federalState}/statistic", produces = {"application/json"})
public ResponseEntity<List<LocationStatisticResponse>> getStatisticByFederalState(@PathVariable String federalState) {
List<Operation> operations = operationService.getActiveOperationsByFederalState(federalState);
Expand All @@ -92,6 +105,7 @@ public ResponseEntity<OperationResponse> getOperationByUuid(@PathVariable String
if (operation == null) {
return ResponseEntity.notFound().build();
} else {
statisticService.setStatForDetail("operation", operation.getUuid());
OperationResponse response = operationResponseMapper.fromOperation(operation);
return ResponseEntity.ok(response);
}
Expand Down
Loading
Loading