Compare commits
56 Commits
1d38428f7f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 767b22f945 | |||
| cdbdcc5832 | |||
| 016b9dcd81 | |||
| cebdf72635 | |||
| 478484ce76 | |||
| 78515fb468 | |||
| e4f175526f | |||
| 7bae39fe62 | |||
| 5ab5b05467 | |||
| 118bc4e586 | |||
| 4bf7810206 | |||
| 28d874edd8 | |||
| 3712e2e6f0 | |||
| 9df3e162ce | |||
| e9914e7fa8 | |||
| 3b504aeea8 | |||
| bfb3d976f0 | |||
| 888fb3ff26 | |||
| b7ef8fc870 | |||
| 0e5f8d7b1c | |||
| 6dac9e7cc1 | |||
| aa31cc37d9 | |||
| c8937357e5 | |||
| efd096bb9a | |||
| 8077a1a94e | |||
| a2eef01038 | |||
| 340b33f5d0 | |||
| 2f7d018c70 | |||
| 041acd663e | |||
| e0eab23692 | |||
| fe8a911479 | |||
| 1c6f69c752 | |||
| f75548d08e | |||
| 81375a1acb | |||
| e04d6f98d1 | |||
| 7fa7cbb4c7 | |||
| b21698dec9 | |||
|
|
c5bb58888a | ||
| 4a2171c38d | |||
| a225ab2993 | |||
| 1ec9558bf7 | |||
| 4b7b367730 | |||
| 478c4b93a9 | |||
| 9210fef76e | |||
| cdea9ac03f | |||
| ee681ad1f3 | |||
| 1358b8981c | |||
| 95286ae6dd | |||
| 2fc5ad92d2 | |||
| 27719e7f08 | |||
| 983b14e65d | |||
| 801167b5db | |||
| 0fba48b7ff | |||
| 0ff94c308f | |||
| fd9fa10b3c | |||
| e79c43b89a |
39
.gitea/workflows/build.yml
Executable file
39
.gitea/workflows/build.yml
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build Org Backend
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: site-build
|
||||||
|
env:
|
||||||
|
APP_HOME: /home/zaine/master-folder/org-platform/org_backend
|
||||||
|
SERVICE_NAME: org-backend.service
|
||||||
|
SUDO_PASSWORD: ${{ secrets.SUDO_PASSWORD }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "17"
|
||||||
|
|
||||||
|
- name: Verify toolchain
|
||||||
|
run: |
|
||||||
|
java -version
|
||||||
|
mvn -version
|
||||||
|
|
||||||
|
- name: Build and test
|
||||||
|
run: mvn -B clean verify
|
||||||
|
|
||||||
|
- name: Deploy jar and restart service
|
||||||
|
run: ./scripts/deploy-systemd.sh
|
||||||
7
.gitignore
vendored
Normal file → Executable file
7
.gitignore
vendored
Normal file → Executable file
@@ -1,6 +1,11 @@
|
|||||||
*~
|
*~
|
||||||
target/
|
target/
|
||||||
application.properties
|
application.properties
|
||||||
|
application-*.properties
|
||||||
|
!src/main/resources/application-dev.properties
|
||||||
|
!src/main/resources/application-prod.properties
|
||||||
.settings/
|
.settings/
|
||||||
.project
|
.project
|
||||||
.classpath
|
.classpath
|
||||||
|
.vscode/
|
||||||
|
.env
|
||||||
|
|||||||
0
.mvn/jvm.config
Normal file → Executable file
0
.mvn/jvm.config
Normal file → Executable file
0
.mvn/maven.config
Normal file → Executable file
0
.mvn/maven.config
Normal file → Executable file
33
README.md
Normal file → Executable file
33
README.md
Normal file → Executable file
@@ -8,4 +8,35 @@ This repository contains the source code and documentation for all of my APIs. I
|
|||||||
|
|
||||||
The repository is organised into the following main directories:
|
The repository is organised into the following main directories:
|
||||||
|
|
||||||
This is a test
|
# Deployment
|
||||||
|
|
||||||
|
The main branch build is controlled by `.gitea/workflows/build.yml`. On a
|
||||||
|
successful build it packages the Spring Boot jar, installs the project-owned
|
||||||
|
`misc/org-backend.service` unit into systemd, reloads systemd, enables the
|
||||||
|
service, and restarts it.
|
||||||
|
|
||||||
|
The service runs the stable build artifact at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/zaine/master-folder/org-platform/org_backend/target/org-backend.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime configuration is loaded by systemd from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/org-backend/org-backend.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Create that file once on the server with the production values required by the
|
||||||
|
app. The Gitea deploy step verifies that it exists, but it does not create or
|
||||||
|
overwrite it.
|
||||||
|
|
||||||
|
Datasource credentials and `JWT_SECRET` are required environment values; they
|
||||||
|
are intentionally not stored in the Spring profile files. Start from
|
||||||
|
`misc/org-backend.env.example` and keep the populated file outside the
|
||||||
|
repository. Rotate any credential that was previously committed before using
|
||||||
|
this version in production.
|
||||||
|
|
||||||
|
The live systemd unit at `/etc/systemd/system/org-backend.service` is managed
|
||||||
|
as a symlink to `misc/org-backend.service`, so the project copy is the source of
|
||||||
|
truth.
|
||||||
|
|||||||
23
backups/systemd-20260604-121946/org-backend.service.permanent-copy
Executable file
23
backups/systemd-20260604-121946/org-backend.service.permanent-copy
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Org Backend Spring Boot App
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=zaine
|
||||||
|
Group=zaine
|
||||||
|
Environment=SPRING_PROFILES_ACTIVE=prod
|
||||||
|
EnvironmentFile=/etc/org-backend/org-backend.env
|
||||||
|
|
||||||
|
WorkingDirectory=/home/zaine/master-folder/projects/java_projects/org_backend
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/java -jar /home/zaine/master-folder/projects/java_projects/org_backend/target/org-backend.jar
|
||||||
|
|
||||||
|
SuccessExitStatus=143
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Optional: limit memory
|
||||||
|
# Environment="JAVA_OPTS=-Xms256m -Xmx1g"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
1
backups/systemd-20260604-121946/org-backend.service.symlink-target
Executable file
1
backups/systemd-20260604-121946/org-backend.service.symlink-target
Executable file
@@ -0,0 +1 @@
|
|||||||
|
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||||
1
backups/systemd-20260604-121946/org-backend.service.wants-symlink-target
Executable file
1
backups/systemd-20260604-121946/org-backend.service.wants-symlink-target
Executable file
@@ -0,0 +1 @@
|
|||||||
|
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||||
6
backups/systemd-20260604-121946/summary.txt
Executable file
6
backups/systemd-20260604-121946/summary.txt
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
timestamp=20260604-121946
|
||||||
|
unit=/etc/systemd/system/org-backend.service
|
||||||
|
unit_target=/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||||
|
wants=/etc/systemd/system/multi-user.target.wants/org-backend.service
|
||||||
|
wants_target=/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service
|
||||||
|
permanent=/home/zaine/master-folder/projects/java_projects/org_backend/misc/org-backend.service
|
||||||
2
data/resource-loader/.gitignore
vendored
Executable file
2
data/resource-loader/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
library.json
|
||||||
|
thumbnails/
|
||||||
44
data/rpg-saves/ash-below-the-lake.json
Executable file
44
data/rpg-saves/ash-below-the-lake.json
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"slot" : "ash-below-the-lake",
|
||||||
|
"payload" : {
|
||||||
|
"started" : true,
|
||||||
|
"mode" : "world",
|
||||||
|
"area" : "harbor",
|
||||||
|
"player" : {
|
||||||
|
"x" : 16,
|
||||||
|
"y" : 7,
|
||||||
|
"facing" : "right",
|
||||||
|
"step" : 48.69999999999709
|
||||||
|
},
|
||||||
|
"route" : "Undecided",
|
||||||
|
"resolve" : 20,
|
||||||
|
"toldName" : null,
|
||||||
|
"flags" : {
|
||||||
|
"metMira" : true
|
||||||
|
},
|
||||||
|
"inventory" : [ ],
|
||||||
|
"memories" : [ {
|
||||||
|
"id" : "letter",
|
||||||
|
"text" : "Your sibling's letter says: Don't let the lake remember your name.",
|
||||||
|
"stable" : true
|
||||||
|
}, {
|
||||||
|
"id" : "town",
|
||||||
|
"text" : "Morrow's End smiles like a town in a postcard someone tried to burn.",
|
||||||
|
"stable" : true
|
||||||
|
}, {
|
||||||
|
"id" : "m1778365899690",
|
||||||
|
"text" : "Mira remembers what other people lose.",
|
||||||
|
"stable" : true
|
||||||
|
} ],
|
||||||
|
"forgets" : 0,
|
||||||
|
"listens" : 0,
|
||||||
|
"confronts" : 0,
|
||||||
|
"companion" : true,
|
||||||
|
"battle" : null,
|
||||||
|
"ending" : null,
|
||||||
|
"dialogue" : {
|
||||||
|
"speaker" : "Ilyas",
|
||||||
|
"line" : "The letter is damp, but the ink has not run. Morrow's End waits beyond the harbor lamps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
misc/Dockerfile
Executable file
32
misc/Dockerfile
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
# -------- Build stage --------
|
||||||
|
FROM maven:3.9.6-eclipse-temurin-21 AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pom.xml .
|
||||||
|
RUN mvn -B dependency:go-offline
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mvn -B clean package -DskipTests
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Runtime stage --------
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
make \
|
||||||
|
emacs-nox \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
|
python3-pip \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
|
|
||||||
|
EXPOSE 9010
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
51
misc/org-backend.env.example
Executable file
51
misc/org-backend.env.example
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
# Copy to /etc/org-backend/org-backend.env (systemd EnvironmentFile)
|
||||||
|
# Paths must use org-platform/ after Phase 1 reorg — not org_files/
|
||||||
|
|
||||||
|
SERVER_PORT=9010
|
||||||
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
|
SPRING_DATASOURCE_URL=jdbc:postgresql://127.0.0.1:5432/org_web
|
||||||
|
SPRING_DATASOURCE_USERNAME=zaine
|
||||||
|
SPRING_DATASOURCE_PASSWORD=change-me
|
||||||
|
SPRING_DATASOURCE_DRIVER_CLASS_NAME=org.postgresql.Driver
|
||||||
|
SPRING_JPA_SHOW_SQL=false
|
||||||
|
|
||||||
|
CALIBRE_DB_PATH=/home/zaine/master-folder/projects/calibre/library/metadata.db
|
||||||
|
JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-characters
|
||||||
|
JWT_EXPIRATION_MS=86400000
|
||||||
|
|
||||||
|
ZONE_BUILD_DIR=/home/zaine/master-folder/org-platform/org_web
|
||||||
|
ZONE_BUILD_LOG=/home/zaine/master-folder/org-platform/org_web/org-web-build.log
|
||||||
|
ZONE_MANIFEST_PATH=/home/zaine/master-folder/org-platform/zone/data/manifest.json
|
||||||
|
ZONE_AUTHORING_URL=http://127.0.0.1:8765
|
||||||
|
ZONE_WATCHER_UNIT=watcher.service
|
||||||
|
ZONE_SCRIPTS_LOG_DIR=/home/zaine/logs
|
||||||
|
ZONE_RESOURCES_STORAGE_PATH=/
|
||||||
|
|
||||||
|
EMACS_RUN_DIR=/home/zaine
|
||||||
|
EMACS_RUN_LOG=/home/zaine/logs/emacs.log
|
||||||
|
COMBINED_RUN_LOG=/home/zaine/logs/combined.log
|
||||||
|
ADVENTURE_RESOURCES_FREE_DIR=/home/zaine
|
||||||
|
ADVENTURE_RESOURCES_FREE_LOG=/home/zaine/logs/adventure-resources.log
|
||||||
|
ADVENTURE_RESOURCES_FREE_COMMAND="sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af"
|
||||||
|
GUACAMOLE_RUN_DIR=/home/zaine
|
||||||
|
GUACAMOLE_CONTAINER_NAME=guacamole
|
||||||
|
GUACAMOLE_START_LOG=/home/zaine/logs/guacamole-start.log
|
||||||
|
GUACAMOLE_STOP_LOG=/home/zaine/logs/guacamole-stop.log
|
||||||
|
NOSTALGIA_RUN_LOG=/home/zaine/logs/nostalgia-prod.log
|
||||||
|
|
||||||
|
PLAY_RPG_SAVE_DIR=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves
|
||||||
|
|
||||||
|
# Calendar sync
|
||||||
|
NEXTCLOUD_CALDAV_URL=https://nextcloud.zainezq.com/remote.php/dav/calendars/zaine/
|
||||||
|
NEXTCLOUD_CALDAV_CALENDARS=personal,zxh
|
||||||
|
NEXTCLOUD_CALDAV_USERNAME=zaine
|
||||||
|
NEXTCLOUD_CALDAV_PASSWORD=change-me-to-a-nextcloud-app-password
|
||||||
|
CALENDAR_TIMEZONE=Europe/London
|
||||||
|
CALENDAR_SYNC_DAYS_BACK=30
|
||||||
|
CALENDAR_SYNC_DAYS_FORWARD=365
|
||||||
|
# Optional: external ICS feed for work calendar
|
||||||
|
WORK_ICS_URL=
|
||||||
|
|
||||||
|
# Leave unset on private zone — timesheet API read/write without credentials
|
||||||
|
# ORG_BACKEND_API_KEY=
|
||||||
|
TIMESHEET_AUTH_REQUIRED=false
|
||||||
23
misc/org-backend.service
Executable file
23
misc/org-backend.service
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Org Backend Spring Boot App
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=zaine
|
||||||
|
Group=zaine
|
||||||
|
Environment=SPRING_PROFILES_ACTIVE=prod
|
||||||
|
EnvironmentFile=/etc/org-backend/org-backend.env
|
||||||
|
|
||||||
|
WorkingDirectory=/home/zaine/master-folder/org-platform/org_backend
|
||||||
|
|
||||||
|
ExecStart=/usr/bin/java -jar /home/zaine/master-folder/org-platform/org_backend/target/org-backend.jar
|
||||||
|
|
||||||
|
SuccessExitStatus=143
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Optional: limit memory
|
||||||
|
# Environment="JAVA_OPTS=-Xms256m -Xmx1g"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
63
pom.xml
Normal file → Executable file
63
pom.xml
Normal file → Executable file
@@ -26,25 +26,86 @@
|
|||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
||||||
|
<!-- Web / REST -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JPA + Hibernate (managed by Boot) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Schema migrations (runs at startup) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xerial</groupId>
|
||||||
|
<artifactId>sqlite-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.0.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT Dependencies -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson, jjwt-orgjson -->
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mnode.ical4j</groupId>
|
||||||
|
<artifactId>ical4j</artifactId>
|
||||||
|
<version>4.2.5</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
<finalName>org-backend</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
70
scripts/deploy-systemd.sh
Executable file
70
scripts/deploy-systemd.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_HOME="${APP_HOME:-/home/zaine/master-folder/org-platform/org_backend}"
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-org-backend.service}"
|
||||||
|
SYSTEMCTL_TIMEOUT="${SYSTEMCTL_TIMEOUT:-120}"
|
||||||
|
ENV_FILE="${ENV_FILE:-/etc/org-backend/org-backend.env}"
|
||||||
|
|
||||||
|
SOURCE_DIR="$(pwd)"
|
||||||
|
SOURCE_JAR="${SOURCE_DIR}/target/org-backend.jar"
|
||||||
|
APP_TARGET_DIR="${APP_HOME}/target"
|
||||||
|
APP_JAR="${APP_TARGET_DIR}/org-backend.jar"
|
||||||
|
SOURCE_SERVICE="${SOURCE_DIR}/misc/${SERVICE_NAME}"
|
||||||
|
SYSTEMD_SERVICE="/etc/systemd/system/${SERVICE_NAME}"
|
||||||
|
|
||||||
|
run_sudo() {
|
||||||
|
if [[ -n "${SUDO_PASSWORD:-}" ]]; then
|
||||||
|
printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p '' "$@"
|
||||||
|
else
|
||||||
|
sudo -n "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -f "${SOURCE_JAR}" ]]; then
|
||||||
|
echo "Missing built jar: ${SOURCE_JAR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${SOURCE_SERVICE}" ]]; then
|
||||||
|
echo "Missing service file: ${SOURCE_SERVICE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${APP_TARGET_DIR}"
|
||||||
|
|
||||||
|
if [[ "$(realpath "${SOURCE_JAR}")" != "$(realpath -m "${APP_JAR}")" ]]; then
|
||||||
|
echo "Installing jar to ${APP_JAR}"
|
||||||
|
install -m 0644 "${SOURCE_JAR}" "${APP_JAR}"
|
||||||
|
else
|
||||||
|
echo "Built jar is already in place at ${APP_JAR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking sudo access"
|
||||||
|
run_sudo true
|
||||||
|
|
||||||
|
echo "Checking runtime env file at ${ENV_FILE}"
|
||||||
|
if ! run_sudo test -f "${ENV_FILE}"; then
|
||||||
|
echo "Missing ${ENV_FILE}. Create it on the server with the production values required by the app." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Linking project systemd unit to ${SYSTEMD_SERVICE}"
|
||||||
|
run_sudo rm -f "${SYSTEMD_SERVICE}"
|
||||||
|
run_sudo ln -s "${SOURCE_SERVICE}" "${SYSTEMD_SERVICE}"
|
||||||
|
|
||||||
|
echo "Reloading systemd"
|
||||||
|
run_sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
echo "Enabling ${SERVICE_NAME}"
|
||||||
|
run_sudo systemctl enable "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
echo "Restarting ${SERVICE_NAME}"
|
||||||
|
if ! timeout "${SYSTEMCTL_TIMEOUT}" bash -c "$(declare -f run_sudo); run_sudo systemctl --no-ask-password restart \"${SERVICE_NAME}\""; then
|
||||||
|
echo "Restart did not finish within ${SYSTEMCTL_TIMEOUT}s" >&2
|
||||||
|
run_sudo systemctl --no-pager --full status "${SERVICE_NAME}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Service status"
|
||||||
|
run_sudo systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||||
2
src/main/java/org/zaine/app/Application.java
Normal file → Executable file
2
src/main/java/org/zaine/app/Application.java
Normal file → Executable file
@@ -2,9 +2,11 @@ package org.zaine.app;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class Application {
|
public class Application {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(Application.class, args);
|
SpringApplication.run(Application.class, args);
|
||||||
|
|||||||
31
src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java
Executable file
31
src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
package org.zaine.app.calibre.adapter.in.web;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.calibre.application.port.in.BrowseCalibreBooksUseCase;
|
||||||
|
import org.zaine.app.calibre.domain.CalibreBook;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/calibre")
|
||||||
|
@Tag(name = "Calibre", description = "Calibre catalogue")
|
||||||
|
public class CalibreController {
|
||||||
|
private final BrowseCalibreBooksUseCase books;
|
||||||
|
|
||||||
|
public CalibreController(BrowseCalibreBooksUseCase books) {
|
||||||
|
this.books = books;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/books")
|
||||||
|
public List<BookResponse> getBooks() {
|
||||||
|
return books.getBooks().stream().map(BookResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BookResponse(long id, String title, String authors) {
|
||||||
|
static BookResponse from(CalibreBook book) {
|
||||||
|
return new BookResponse(book.id(), book.title(), book.authors());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.zaine.app.calibre.adapter.out.sqlite;
|
||||||
|
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
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.stereotype.Component;
|
||||||
|
import org.zaine.app.calibre.application.port.out.CalibreCatalogPort;
|
||||||
|
import org.zaine.app.calibre.domain.CalibreBook;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class SqliteCalibreCatalog implements CalibreCatalogPort {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SqliteCalibreCatalog.class);
|
||||||
|
private static final String QUERY = """
|
||||||
|
SELECT b.id AS book_id, b.title AS title, GROUP_CONCAT(a.name, ', ') AS authors
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN books_authors_link bal ON b.id = bal.book
|
||||||
|
LEFT JOIN authors a ON bal.author = a.id
|
||||||
|
GROUP BY b.id
|
||||||
|
ORDER BY b.title COLLATE NOCASE
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final String databasePath;
|
||||||
|
|
||||||
|
SqliteCalibreCatalog(@Value("${calibre.db.path}") String databasePath) {
|
||||||
|
this.databasePath = databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CalibreBook> findAll() {
|
||||||
|
List<CalibreBook> books = new ArrayList<>();
|
||||||
|
try (var connection = DriverManager.getConnection("jdbc:sqlite:" + databasePath);
|
||||||
|
var statement = connection.createStatement();
|
||||||
|
var results = statement.executeQuery(QUERY)) {
|
||||||
|
while (results.next()) {
|
||||||
|
books.add(new CalibreBook(results.getLong("book_id"), results.getString("title"),
|
||||||
|
results.getString("authors")));
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Calibre catalogue is unavailable at {}: {}", databasePath, ex.getMessage());
|
||||||
|
}
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.zaine.app.calibre.application.port.in;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.zaine.app.calibre.domain.CalibreBook;
|
||||||
|
|
||||||
|
public interface BrowseCalibreBooksUseCase {
|
||||||
|
List<CalibreBook> getBooks();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.zaine.app.calibre.application.port.out;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.zaine.app.calibre.domain.CalibreBook;
|
||||||
|
|
||||||
|
public interface CalibreCatalogPort {
|
||||||
|
List<CalibreBook> findAll();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.zaine.app.calibre.application.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.calibre.application.port.in.BrowseCalibreBooksUseCase;
|
||||||
|
import org.zaine.app.calibre.application.port.out.CalibreCatalogPort;
|
||||||
|
import org.zaine.app.calibre.domain.CalibreBook;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CalibreApplicationService implements BrowseCalibreBooksUseCase {
|
||||||
|
private final CalibreCatalogPort catalog;
|
||||||
|
|
||||||
|
public CalibreApplicationService(CalibreCatalogPort catalog) {
|
||||||
|
this.catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CalibreBook> getBooks() {
|
||||||
|
return catalog.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/main/java/org/zaine/app/calibre/domain/CalibreBook.java
Executable file
7
src/main/java/org/zaine/app/calibre/domain/CalibreBook.java
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
package org.zaine.app.calibre.domain;
|
||||||
|
|
||||||
|
public record CalibreBook(long id, String title, String authors) {
|
||||||
|
public CalibreBook {
|
||||||
|
authors = authors == null ? "" : authors;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/main/java/org/zaine/app/common/application/ApplicationException.java
Executable file
36
src/main/java/org/zaine/app/common/application/ApplicationException.java
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
package org.zaine.app.common.application;
|
||||||
|
|
||||||
|
public class ApplicationException extends RuntimeException {
|
||||||
|
public enum Kind { BAD_REQUEST, NOT_FOUND, UNAUTHORIZED, PAYLOAD_TOO_LARGE, FAILURE }
|
||||||
|
|
||||||
|
private final Kind kind;
|
||||||
|
|
||||||
|
private ApplicationException(Kind kind, String message) {
|
||||||
|
super(message == null || message.isBlank() ? "Operation failed" : message);
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Kind kind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationException badRequest(String message) {
|
||||||
|
return new ApplicationException(Kind.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationException notFound(String message) {
|
||||||
|
return new ApplicationException(Kind.NOT_FOUND, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationException unauthorized(String message) {
|
||||||
|
return new ApplicationException(Kind.UNAUTHORIZED, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationException payloadTooLarge(String message) {
|
||||||
|
return new ApplicationException(Kind.PAYLOAD_TOO_LARGE, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationException failure(String message) {
|
||||||
|
return new ApplicationException(Kind.FAILURE, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/main/java/org/zaine/app/config/BuildProperties.java
Executable file
64
src/main/java/org/zaine/app/config/BuildProperties.java
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
package org.zaine.app.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class BuildProperties {
|
||||||
|
private final String webDirectory;
|
||||||
|
private final String webLog;
|
||||||
|
private final String emacsDirectory;
|
||||||
|
private final String emacsLog;
|
||||||
|
private final String combinedLog;
|
||||||
|
private final String resourceDirectory;
|
||||||
|
private final String resourceLog;
|
||||||
|
private final String resourceCommand;
|
||||||
|
private final String guacamoleDirectory;
|
||||||
|
private final String guacamoleContainer;
|
||||||
|
private final String guacamoleStartLog;
|
||||||
|
private final String guacamoleStopLog;
|
||||||
|
private final String nostalgiaLog;
|
||||||
|
|
||||||
|
public BuildProperties(
|
||||||
|
@Value("${zone.build.dir}") String webDirectory,
|
||||||
|
@Value("${zone.build.log}") String webLog,
|
||||||
|
@Value("${emacs.run.dir}") String emacsDirectory,
|
||||||
|
@Value("${emacs.run.log}") String emacsLog,
|
||||||
|
@Value("${combined.run.log}") String combinedLog,
|
||||||
|
@Value("${adventure.resources.free.dir:/home/zaine}") String resourceDirectory,
|
||||||
|
@Value("${adventure.resources.free.log:/home/zaine/logs/adventure-resources.log}") String resourceLog,
|
||||||
|
@Value("${adventure.resources.free.command:sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af}") String resourceCommand,
|
||||||
|
@Value("${guacamole.run.dir:/home/zaine}") String guacamoleDirectory,
|
||||||
|
@Value("${guacamole.container.name:guacamole}") String guacamoleContainer,
|
||||||
|
@Value("${guacamole.start.log:/home/zaine/logs/guacamole-start.log}") String guacamoleStartLog,
|
||||||
|
@Value("${guacamole.stop.log:/home/zaine/logs/guacamole-stop.log}") String guacamoleStopLog,
|
||||||
|
@Value("${nostalgia.run.log:/home/zaine/logs/nostalgia-prod.log}") String nostalgiaLog) {
|
||||||
|
this.webDirectory = webDirectory;
|
||||||
|
this.webLog = webLog;
|
||||||
|
this.emacsDirectory = emacsDirectory;
|
||||||
|
this.emacsLog = emacsLog;
|
||||||
|
this.combinedLog = combinedLog;
|
||||||
|
this.resourceDirectory = resourceDirectory;
|
||||||
|
this.resourceLog = resourceLog;
|
||||||
|
this.resourceCommand = resourceCommand;
|
||||||
|
this.guacamoleDirectory = guacamoleDirectory;
|
||||||
|
this.guacamoleContainer = guacamoleContainer;
|
||||||
|
this.guacamoleStartLog = guacamoleStartLog;
|
||||||
|
this.guacamoleStopLog = guacamoleStopLog;
|
||||||
|
this.nostalgiaLog = nostalgiaLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String webDirectory() { return webDirectory; }
|
||||||
|
public String webLog() { return webLog; }
|
||||||
|
public String emacsDirectory() { return emacsDirectory; }
|
||||||
|
public String emacsLog() { return emacsLog; }
|
||||||
|
public String combinedLog() { return combinedLog; }
|
||||||
|
public String resourceDirectory() { return resourceDirectory; }
|
||||||
|
public String resourceLog() { return resourceLog; }
|
||||||
|
public String resourceCommand() { return resourceCommand; }
|
||||||
|
public String guacamoleDirectory() { return guacamoleDirectory; }
|
||||||
|
public String guacamoleContainer() { return guacamoleContainer; }
|
||||||
|
public String guacamoleStartLog() { return guacamoleStartLog; }
|
||||||
|
public String guacamoleStopLog() { return guacamoleStopLog; }
|
||||||
|
public String nostalgiaLog() { return nostalgiaLog; }
|
||||||
|
}
|
||||||
60
src/main/java/org/zaine/app/config/CalendarSyncProperties.java
Executable file
60
src/main/java/org/zaine/app/config/CalendarSyncProperties.java
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
package org.zaine.app.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CalendarSyncProperties {
|
||||||
|
|
||||||
|
private final String nextcloudBaseUrl;
|
||||||
|
private final String nextcloudUsername;
|
||||||
|
private final String nextcloudPassword;
|
||||||
|
private final List<String> nextcloudCalendarIds;
|
||||||
|
private final String workIcsUrl;
|
||||||
|
private final ZoneId zoneId;
|
||||||
|
private final int daysBack;
|
||||||
|
private final int daysForward;
|
||||||
|
|
||||||
|
public CalendarSyncProperties(
|
||||||
|
@Value("${calendar.nextcloud.base-url:${NEXTCLOUD_CALDAV_URL:}}") String nextcloudBaseUrl,
|
||||||
|
@Value("${calendar.nextcloud.username:${NEXTCLOUD_CALDAV_USERNAME:}}") String nextcloudUsername,
|
||||||
|
@Value("${calendar.nextcloud.password:${NEXTCLOUD_CALDAV_PASSWORD:}}") String nextcloudPassword,
|
||||||
|
@Value("${calendar.nextcloud.calendar-ids:${NEXTCLOUD_CALDAV_CALENDARS:personal,zxh}}") String nextcloudCalendarIds,
|
||||||
|
@Value("${calendar.work.ics-url:${WORK_ICS_URL:}}") String workIcsUrl,
|
||||||
|
@Value("${calendar.timezone:${CALENDAR_TIMEZONE:Europe/London}}") String timezone,
|
||||||
|
@Value("${calendar.sync.days-back:${CALENDAR_SYNC_DAYS_BACK:30}}") int daysBack,
|
||||||
|
@Value("${calendar.sync.days-forward:${CALENDAR_SYNC_DAYS_FORWARD:365}}") int daysForward) {
|
||||||
|
this.nextcloudBaseUrl = trim(nextcloudBaseUrl);
|
||||||
|
this.nextcloudUsername = trim(nextcloudUsername);
|
||||||
|
this.nextcloudPassword = trim(nextcloudPassword);
|
||||||
|
this.nextcloudCalendarIds = Arrays.stream(nextcloudCalendarIds.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isBlank())
|
||||||
|
.toList();
|
||||||
|
this.workIcsUrl = trim(workIcsUrl);
|
||||||
|
this.zoneId = ZoneId.of(timezone);
|
||||||
|
this.daysBack = daysBack;
|
||||||
|
this.daysForward = daysForward;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNextcloudBaseUrl() { return nextcloudBaseUrl; }
|
||||||
|
public String getNextcloudUsername() { return nextcloudUsername; }
|
||||||
|
public String getNextcloudPassword() { return nextcloudPassword; }
|
||||||
|
public List<String> getNextcloudCalendarIds() { return nextcloudCalendarIds; }
|
||||||
|
public String getWorkIcsUrl() { return workIcsUrl; }
|
||||||
|
public ZoneId getZoneId() { return zoneId; }
|
||||||
|
public int getDaysBack() { return daysBack; }
|
||||||
|
public int getDaysForward() { return daysForward; }
|
||||||
|
|
||||||
|
public boolean hasNextcloudCredentials() {
|
||||||
|
return !nextcloudBaseUrl.isBlank() && !nextcloudUsername.isBlank() && !nextcloudPassword.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/main/java/org/zaine/app/config/OpenApiConfig.java
Executable file
44
src/main/java/org/zaine/app/config/OpenApiConfig.java
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
package org.zaine.app.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||||
|
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||||
|
import io.swagger.v3.oas.annotations.info.Info;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.media.Content;
|
||||||
|
import io.swagger.v3.oas.models.media.MediaType;
|
||||||
|
import io.swagger.v3.oas.models.responses.ApiResponse;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@OpenAPIDefinition(info = @Info(title = "org-backend API", version = "1.0.0"))
|
||||||
|
@SecurityScheme(
|
||||||
|
name = "bearerAuth",
|
||||||
|
type = SecuritySchemeType.HTTP,
|
||||||
|
scheme = "bearer",
|
||||||
|
bearerFormat = "JWT"
|
||||||
|
)
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI customOpenAPI() {
|
||||||
|
|
||||||
|
ApiResponse notFoundResponse = new ApiResponse()
|
||||||
|
.description("Resource not found")
|
||||||
|
.content(new Content().addMediaType("application/json",
|
||||||
|
new MediaType().example("""
|
||||||
|
{
|
||||||
|
"message": "Could not find resource with URL: /api/example/123"
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
));
|
||||||
|
|
||||||
|
return new OpenAPI()
|
||||||
|
.components(new Components()
|
||||||
|
.addResponses("NotFound", notFoundResponse)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/org/zaine/app/config/RestClientConfig.java
Executable file
14
src/main/java/org/zaine/app/config/RestClientConfig.java
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
package org.zaine.app.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RestClientConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate() {
|
||||||
|
return new RestTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/main/java/org/zaine/app/controller/AuthController.java
Executable file
77
src/main/java/org/zaine/app/controller/AuthController.java
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
// src/main/java/org/zaine/app/controller/AuthController.java
|
||||||
|
package org.zaine.app.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.zaine.app.security.JwtUtil;
|
||||||
|
import org.zaine.app.user.User;
|
||||||
|
import org.zaine.app.user.UserRepository;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.security.authentication.*;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@Tag(name = "Authentication")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public AuthController(JwtUtil jwtUtil,
|
||||||
|
AuthenticationManager authenticationManager,
|
||||||
|
UserRepository userRepository,
|
||||||
|
PasswordEncoder passwordEncoder) {
|
||||||
|
this.jwtUtil = jwtUtil;
|
||||||
|
this.authenticationManager = authenticationManager;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Register a new user")
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody RegisterRequest req) {
|
||||||
|
if (userRepository.existsByUsername(req.username())) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CONFLICT)
|
||||||
|
.body(Map.of("error", "Username already taken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(req.username());
|
||||||
|
user.setPassword(passwordEncoder.encode(req.password())); // BCrypt hash
|
||||||
|
user.setRole("ROLE_USER");
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CREATED)
|
||||||
|
.body(Map.of("message", "User registered successfully"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Login and receive a JWT token")
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody LoginRequest req) {
|
||||||
|
try {
|
||||||
|
// This checks credentials against the DB via UserDetailsServiceImpl
|
||||||
|
authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(req.username(), req.password())
|
||||||
|
);
|
||||||
|
} catch (AuthenticationException e) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(Map.of("error", "Invalid username or password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtUtil.generateToken(req.username());
|
||||||
|
return ResponseEntity.ok(Map.of("token", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(String username, String password) {}
|
||||||
|
public record RegisterRequest(String username, String password) {}
|
||||||
|
}
|
||||||
75
src/main/java/org/zaine/app/controller/CalendarController.java
Executable file
75
src/main/java/org/zaine/app/controller/CalendarController.java
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
package org.zaine.app.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.dto.CalendarEventDTO;
|
||||||
|
import org.zaine.app.dto.CalendarSyncResultDTO;
|
||||||
|
import org.zaine.app.dto.CreateCalendarEventDTO;
|
||||||
|
import org.zaine.app.service.CalendarService;
|
||||||
|
import org.zaine.app.service.CalendarSyncService;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/calendar")
|
||||||
|
@Tag(name = "Calendar", description = "Calendar events exposed to clients")
|
||||||
|
public class CalendarController {
|
||||||
|
|
||||||
|
private final CalendarService calendarService;
|
||||||
|
private final CalendarSyncService calendarSyncService;
|
||||||
|
|
||||||
|
public CalendarController(CalendarService calendarService, CalendarSyncService calendarSyncService) {
|
||||||
|
this.calendarService = calendarService;
|
||||||
|
this.calendarSyncService = calendarSyncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get today's calendar events")
|
||||||
|
@GetMapping("/today")
|
||||||
|
public List<CalendarEventDTO> getTodayEvents() {
|
||||||
|
return calendarService.getTodayEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get calendar events for a month")
|
||||||
|
@GetMapping("/month")
|
||||||
|
public List<CalendarEventDTO> getMonthEvents(
|
||||||
|
@RequestParam int year,
|
||||||
|
@RequestParam int month) {
|
||||||
|
return calendarService.getMonthEvents(year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get calendar events for a date range")
|
||||||
|
@GetMapping("/range")
|
||||||
|
public List<CalendarEventDTO> getRangeEvents(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
return calendarService.getEventsForRange(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get one calendar event")
|
||||||
|
@GetMapping("/events/{id}")
|
||||||
|
public CalendarEventDTO getEvent(@PathVariable Long id) {
|
||||||
|
return calendarService.getEvent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Create a calendar event")
|
||||||
|
@PostMapping("/events")
|
||||||
|
public ResponseEntity<CalendarEventDTO> createEvent(@RequestBody CreateCalendarEventDTO dto) {
|
||||||
|
return ResponseEntity.ok(calendarService.createEvent(dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Fetch configured external calendars now")
|
||||||
|
@PostMapping("/sync")
|
||||||
|
public ResponseEntity<CalendarSyncResultDTO> syncCalendar() {
|
||||||
|
return ResponseEntity.ok(calendarSyncService.sync());
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/main/java/org/zaine/app/controller/CommentsController.java
Executable file
119
src/main/java/org/zaine/app/controller/CommentsController.java
Executable file
@@ -0,0 +1,119 @@
|
|||||||
|
package org.zaine.app.controller;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
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.RestController;
|
||||||
|
import org.zaine.app.dto.CreateCommentDTO;
|
||||||
|
import org.zaine.app.model.Comments;
|
||||||
|
import org.zaine.app.service.CommentsService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/comments")
|
||||||
|
@Tag(name = "Comments", description = "Endpoints for managing comments")
|
||||||
|
public class CommentsController {
|
||||||
|
|
||||||
|
private final CommentsService commentsService;
|
||||||
|
|
||||||
|
public CommentsController(CommentsService commentsService) {
|
||||||
|
this.commentsService = commentsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get comments by page slug",
|
||||||
|
description = "Fetches all comments associated with a given page slug"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Comments retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No comments found for this slug"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/{page_slug}")
|
||||||
|
public List<CommentResponse> getAllCommentsBySlug(@PathVariable String page_slug) {
|
||||||
|
return commentsService.getAllCommentsBySlug(page_slug).stream().map(CommentResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get a comment by id",
|
||||||
|
description = "Fetches a comment by its id"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Comment retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No comment found for this id"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/item/{id}")
|
||||||
|
public CommentResponse getCommentById(@PathVariable Integer id) {
|
||||||
|
return CommentResponse.from(commentsService.getCommentById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get all comments",
|
||||||
|
description = "Fetches all comments from the database"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Comments retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No comments found"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("")
|
||||||
|
public List<CommentResponse> getAllComments() {
|
||||||
|
return commentsService.getAllComments().stream().map(CommentResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Add a comment",
|
||||||
|
description = "Adds a comment to the database"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Comment added successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No comments found"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping(
|
||||||
|
value = "",
|
||||||
|
consumes = "application/json"
|
||||||
|
)
|
||||||
|
public void addComment(@RequestBody CreateCommentDTO dto) {
|
||||||
|
|
||||||
|
commentsService.addComment(dto.getPageSlug(), dto.getAuthor(), dto.getContent(), dto.getParentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get a comment thread",
|
||||||
|
description = "Fetches a comment thread by its id"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Comment thread retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No comment thread found for this id"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/thread/{comment_id}")
|
||||||
|
public List<CommentResponse> getCommentThread(@PathVariable Integer comment_id) {
|
||||||
|
return commentsService.getCommentThread(comment_id).stream().map(CommentResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CommentResponse(
|
||||||
|
Integer id,
|
||||||
|
String pageSlug,
|
||||||
|
String author,
|
||||||
|
String content,
|
||||||
|
@JsonProperty("created_at") java.time.Instant createdAt,
|
||||||
|
@JsonProperty("parent_id") Integer parentId) {
|
||||||
|
static CommentResponse from(Comments comment) {
|
||||||
|
if (comment == null) return null;
|
||||||
|
return new CommentResponse(comment.getId(), comment.getPageSlug(), comment.getAuthor(),
|
||||||
|
comment.getContent(), comment.getCreatedAt(), comment.getParentId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/main/java/org/zaine/app/controller/CompetenciesController.java
Executable file
90
src/main/java/org/zaine/app/controller/CompetenciesController.java
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
package org.zaine.app.controller;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.dto.CompetenciesDTO;
|
||||||
|
import org.zaine.app.model.Competencies;
|
||||||
|
import org.zaine.app.service.CompetenciesService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/competencies")
|
||||||
|
@Tag(name = "Competencies", description = "Operations about competencies")
|
||||||
|
public class CompetenciesController {
|
||||||
|
|
||||||
|
private final CompetenciesService competenciesService;
|
||||||
|
|
||||||
|
public CompetenciesController(CompetenciesService competenciesService) {
|
||||||
|
this.competenciesService = competenciesService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get all competencies",
|
||||||
|
description = "Returns all competencies"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Competencies retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No competencies found"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/items")
|
||||||
|
public List<CompetencyResponse> getAllCompetencies(
|
||||||
|
@RequestParam(value = "group", required = false) String group) {
|
||||||
|
if (group != null && !group.isBlank()) {
|
||||||
|
return competenciesService.getCompetenciesByGroup(group).stream().map(CompetencyResponse::from).toList();
|
||||||
|
}
|
||||||
|
return competenciesService.getAllCompetencies().stream().map(CompetencyResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get a competency by id",
|
||||||
|
description = "Fetches a competency by its id"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Competency retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No competency found for this id"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/item/{id}")
|
||||||
|
public CompetencyResponse getCompetencyById(@PathVariable Integer id) {
|
||||||
|
return CompetencyResponse.from(competenciesService.getCompetencyById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Update a competency state",
|
||||||
|
description = "Updates the state of a competency"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Competency state updated successfully"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "No competency found for this id"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping(
|
||||||
|
value = "/items/{id}/state",
|
||||||
|
consumes = "application/json"
|
||||||
|
)
|
||||||
|
public void updateCompetencyState(
|
||||||
|
@PathVariable Integer id,
|
||||||
|
@RequestBody CompetenciesDTO request
|
||||||
|
) {
|
||||||
|
competenciesService.updateCompetencyState(id, request.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CompetencyResponse(Integer id, String title, String state, java.time.Instant createdAt, String group) {
|
||||||
|
static CompetencyResponse from(Competencies competency) {
|
||||||
|
if (competency == null) return null;
|
||||||
|
return new CompetencyResponse(competency.getId(), competency.getTitle(), competency.getState(),
|
||||||
|
competency.getCreatedAt(), competency.getGroup());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.zaine.app;
|
|
||||||
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
public class HealthController {
|
|
||||||
|
|
||||||
@GetMapping("/health")
|
|
||||||
public String health() {
|
|
||||||
return "OK";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
src/main/java/org/zaine/app/controller/RpgSaveController.java
Executable file
45
src/main/java/org/zaine/app/controller/RpgSaveController.java
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
package org.zaine.app.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.dto.RpgSaveDTO;
|
||||||
|
import org.zaine.app.service.RpgSaveService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/play/rpg")
|
||||||
|
@Tag(name = "RPG Saves", description = "Persistent save slots for browser RPGs")
|
||||||
|
public class RpgSaveController {
|
||||||
|
private final RpgSaveService rpgSaveService;
|
||||||
|
|
||||||
|
public RpgSaveController(RpgSaveService rpgSaveService) {
|
||||||
|
this.rpgSaveService = rpgSaveService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/save/{slot}")
|
||||||
|
public ResponseEntity<RpgSaveDTO> load(@PathVariable String slot) {
|
||||||
|
RpgSaveDTO save = rpgSaveService.load(slot);
|
||||||
|
if (save == null) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(save);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/save/{slot}")
|
||||||
|
public ResponseEntity<RpgSaveDTO> save(@PathVariable String slot, @RequestBody RpgSaveDTO dto) {
|
||||||
|
return ResponseEntity.ok(rpgSaveService.save(slot, dto.getPayload()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/save/{slot}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable String slot) {
|
||||||
|
rpgSaveService.delete(slot);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/main/java/org/zaine/app/controller/WirdController.java
Executable file
200
src/main/java/org/zaine/app/controller/WirdController.java
Executable file
@@ -0,0 +1,200 @@
|
|||||||
|
package org.zaine.app.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.dto.MotalahSessionDTO;
|
||||||
|
import org.zaine.app.dto.WirdEntryDTO;
|
||||||
|
import org.zaine.app.model.MotalahSession;
|
||||||
|
import org.zaine.app.model.WirdEntry;
|
||||||
|
import org.zaine.app.service.MotalahService;
|
||||||
|
import org.zaine.app.service.WirdService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/wird")
|
||||||
|
@Tag(name = "Wird", description = "Wird API")
|
||||||
|
public class WirdController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WirdController.class);
|
||||||
|
|
||||||
|
private final WirdService wirdService;
|
||||||
|
private final MotalahService motalahService;
|
||||||
|
|
||||||
|
public WirdController(WirdService wirdService, MotalahService motalahService) {
|
||||||
|
this.wirdService = wirdService;
|
||||||
|
this.motalahService = motalahService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries
|
||||||
|
* Returns all entries, newest first. Used by history table and today cards.
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries")
|
||||||
|
public List<WirdEntryResponse> getAllEntries() {
|
||||||
|
log.debug("Fetching all wird entries");
|
||||||
|
return toWirdResponses(wirdService.getAllEntries());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries/today
|
||||||
|
* Convenience endpoint for today's entries only.
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries/today")
|
||||||
|
public List<WirdEntryResponse> getTodayEntries() {
|
||||||
|
return toWirdResponses(wirdService.getTodayEntries());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries/range?from=2025-01-01&to=2025-01-31
|
||||||
|
* Used by trend chart to fetch a date window.
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries/range")
|
||||||
|
public List<WirdEntryResponse> getEntriesInRange(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
return toWirdResponses(wirdService.getEntriesInRange(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries/range?from=...&to=...&type=durood
|
||||||
|
* Filtered by wird type — useful if you want to extend the chart later.
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries/type/{type}")
|
||||||
|
public List<WirdEntryResponse> getEntriesByType(
|
||||||
|
@PathVariable String type,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
return toWirdResponses(wirdService.getEntriesByTypeInRange(type, from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wird/entries
|
||||||
|
* Body: { wirdType, date, value, notes }
|
||||||
|
* Creates a new log entry.
|
||||||
|
*/
|
||||||
|
@PostMapping("/entries")
|
||||||
|
public ResponseEntity<WirdEntryResponse> createEntry(@RequestBody WirdEntryDTO dto) {
|
||||||
|
log.info("Creating wird entry type={} date={}", dto.getWirdType(), dto.getDate());
|
||||||
|
WirdEntry saved = wirdService.createEntry(dto);
|
||||||
|
return ResponseEntity.ok(WirdEntryResponse.from(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/wird/entries/{id}
|
||||||
|
* Deletes a log entry.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/entries/{id}")
|
||||||
|
public ResponseEntity<Void> deleteEntry(@PathVariable Long id) {
|
||||||
|
log.info("Deleting wird entry id={}", id);
|
||||||
|
wirdService.deleteEntry(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries/nafl/today
|
||||||
|
* Returns today's nafl prayer entries (convenience endpoint).
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries/nafl/today")
|
||||||
|
public List<WirdEntryResponse> getNaflToday() {
|
||||||
|
return toWirdResponses(wirdService.getNaflForDate(LocalDate.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/entries/khatm
|
||||||
|
* All khatm completions, newest first.
|
||||||
|
*/
|
||||||
|
@GetMapping("/entries/khatm")
|
||||||
|
public List<WirdEntryResponse> getKhatmEntries() {
|
||||||
|
return toWirdResponses(wirdService.getKhatmEntries());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/motalah
|
||||||
|
* All study sessions, newest first.
|
||||||
|
*/
|
||||||
|
@GetMapping("/motalah")
|
||||||
|
public List<MotalahResponse> getAll() {
|
||||||
|
log.debug("Fetching all motalah sessions");
|
||||||
|
return toMotalahResponses(motalahService.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/motalah/today
|
||||||
|
* Convenience endpoint for today's sessions only.
|
||||||
|
*/
|
||||||
|
@GetMapping("/motalah/today")
|
||||||
|
public List<MotalahResponse> getToday() {
|
||||||
|
return toMotalahResponses(motalahService.getToday());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wird/motalah/range?from=2025-01-01&to=2025-01-31
|
||||||
|
* Used by the trend chart and month-total stat.
|
||||||
|
*/
|
||||||
|
@GetMapping("/motalah/range")
|
||||||
|
public List<MotalahResponse> getInRange(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
return toMotalahResponses(motalahService.getInRange(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wird/motalah
|
||||||
|
* Body: { date, durationMinutes, bookIds, notes }
|
||||||
|
*/
|
||||||
|
@PostMapping("/motalah")
|
||||||
|
public ResponseEntity<MotalahResponse> create(@RequestBody MotalahSessionDTO dto) {
|
||||||
|
log.info("Creating motalah session durationMinutes={} date={}", dto.getDurationMinutes(), dto.getDate());
|
||||||
|
MotalahSession saved = motalahService.create(dto);
|
||||||
|
return ResponseEntity.ok(MotalahResponse.from(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/wird/motalah/{id}
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/motalah/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id) {
|
||||||
|
log.info("Deleting motalah session id={}", id);
|
||||||
|
motalahService.delete(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<WirdEntryResponse> toWirdResponses(List<WirdEntry> entries) {
|
||||||
|
return entries.stream().map(WirdEntryResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<MotalahResponse> toMotalahResponses(List<MotalahSession> sessions) {
|
||||||
|
return sessions.stream().map(MotalahResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record WirdEntryResponse(Long id, String wirdType, LocalDate date, java.math.BigDecimal value,
|
||||||
|
String notes, java.time.OffsetDateTime createdAt) {
|
||||||
|
static WirdEntryResponse from(WirdEntry entry) {
|
||||||
|
return new WirdEntryResponse(entry.getId(), entry.getWirdType(), entry.getDate(), entry.getValue(),
|
||||||
|
entry.getNotes(), entry.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MotalahResponse(Long id, LocalDate date, Integer durationMinutes, List<Integer> bookIds,
|
||||||
|
String notes, java.time.OffsetDateTime createdAt) {
|
||||||
|
static MotalahResponse from(MotalahSession session) {
|
||||||
|
return new MotalahResponse(session.getId(), session.getDate(), session.getDurationMinutes(),
|
||||||
|
session.getBookIds(), session.getNotes(), session.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
831
src/main/java/org/zaine/app/controller/zone/BuildController.java
Executable file
831
src/main/java/org/zaine/app/controller/zone/BuildController.java
Executable file
@@ -0,0 +1,831 @@
|
|||||||
|
package org.zaine.app.controller.zone;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.zaine.app.config.BuildProperties;
|
||||||
|
import org.zaine.app.service.BuildRunStateService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@Tag(name = "Build", description = "Build management API")
|
||||||
|
public class BuildController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BuildController.class);
|
||||||
|
private static final Instant SERVER_START = Instant.now();
|
||||||
|
|
||||||
|
private static final Pattern ANSI_ESCAPE = Pattern.compile(
|
||||||
|
"\u001B(?:\\[[0-9;]*[A-Za-z]|[^\\[])");
|
||||||
|
|
||||||
|
private static final Map<String, String> SGR_CLASS = Map.ofEntries(
|
||||||
|
Map.entry("0", "ansi-reset"),
|
||||||
|
Map.entry("1", "ansi-bold"),
|
||||||
|
Map.entry("2", "ansi-dim"),
|
||||||
|
Map.entry("31", "ansi-red"),
|
||||||
|
Map.entry("32", "ansi-green"),
|
||||||
|
Map.entry("33", "ansi-yellow"),
|
||||||
|
Map.entry("34", "ansi-blue"),
|
||||||
|
Map.entry("35", "ansi-magenta"),
|
||||||
|
Map.entry("36", "ansi-cyan"),
|
||||||
|
Map.entry("37", "ansi-white")
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BuildRunStateService buildRunState;
|
||||||
|
|
||||||
|
private final AtomicReference<Process> webProcess = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Process> emacsProcess = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Process> resourceProcess = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Process> guacamoleStartProcess = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Process> guacamoleStopProcess = new AtomicReference<>();
|
||||||
|
|
||||||
|
public BuildController(BuildRunStateService buildRunState, BuildProperties properties) {
|
||||||
|
this.buildRunState = buildRunState;
|
||||||
|
this.webBuildDirectory = properties.webDirectory();
|
||||||
|
this.webBuildLogFile = properties.webLog();
|
||||||
|
this.emacsRunDirectory = properties.emacsDirectory();
|
||||||
|
this.emacsRunLogFile = properties.emacsLog();
|
||||||
|
this.combinedRunLogFile = properties.combinedLog();
|
||||||
|
this.resourceFreeDirectory = properties.resourceDirectory();
|
||||||
|
this.resourceFreeLogFile = properties.resourceLog();
|
||||||
|
this.resourceFreeCommand = properties.resourceCommand();
|
||||||
|
this.guacamoleRunDirectory = properties.guacamoleDirectory();
|
||||||
|
this.guacamoleContainerName = properties.guacamoleContainer();
|
||||||
|
this.guacamoleStartLogFile = properties.guacamoleStartLog();
|
||||||
|
this.guacamoleStopLogFile = properties.guacamoleStopLog();
|
||||||
|
this.nostalgiaRunLogFile = properties.nostalgiaLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
CONFIG
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
private final String webBuildDirectory;
|
||||||
|
private final String webBuildLogFile;
|
||||||
|
private final String emacsRunDirectory;
|
||||||
|
private final String emacsRunLogFile;
|
||||||
|
private final String combinedRunLogFile;
|
||||||
|
private final String resourceFreeDirectory;
|
||||||
|
private final String resourceFreeLogFile;
|
||||||
|
private final String resourceFreeCommand;
|
||||||
|
private final String guacamoleRunDirectory;
|
||||||
|
private final String guacamoleContainerName;
|
||||||
|
private final String guacamoleStartLogFile;
|
||||||
|
private final String guacamoleStopLogFile;
|
||||||
|
private final String nostalgiaRunLogFile;
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
HEALTH + UPTIME
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get health",
|
||||||
|
description = "Returns a simple health check"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Healthy"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/health")
|
||||||
|
public ResponseEntity<String> health() {
|
||||||
|
return ResponseEntity.ok("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get uptime",
|
||||||
|
description = "Returns the uptime of the server"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Uptime retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/uptime")
|
||||||
|
public ResponseEntity<Map<String, Object>> getUptime() {
|
||||||
|
long uptimeMs = Instant.now().toEpochMilli() - SERVER_START.toEpochMilli();
|
||||||
|
long jvmUptimeMs = ManagementFactory.getRuntimeMXBean().getUptime();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"serverStart", SERVER_START,
|
||||||
|
"uptimeMs", uptimeMs,
|
||||||
|
"jvmUptimeMs", jvmUptimeMs
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
WEB BUILD
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Trigger web build",
|
||||||
|
description = "Triggers a build of the web application"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Build triggered successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping("/build-web")
|
||||||
|
public ResponseEntity<String> triggerWebBuild() {
|
||||||
|
return startBuild(webBuildDirectory, webBuildLogFile, true, webProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Cancel web build",
|
||||||
|
description = "Cancels a running web build"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Build cancelled successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@DeleteMapping("/build-web")
|
||||||
|
public ResponseEntity<String> cancelWebBuild() {
|
||||||
|
return killProcess(webProcess, true, "web build");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get web build status",
|
||||||
|
description = "Returns the status of a running web build"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Build status retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/build-web/status")
|
||||||
|
public ResponseEntity<BuildStatus> getWebBuildStatus() {
|
||||||
|
var s = buildRunState.webStatus();
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Stream web build logs",
|
||||||
|
description = "Streams the logs of a running web build"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Logs streamed successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/build-web/logs")
|
||||||
|
public SseEmitter streamWebLogs() {
|
||||||
|
return streamLogs(webBuildLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
EMACS RERUN
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Trigger Emacs rerun",
|
||||||
|
description = "Triggers a rerun of Emacs"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Emacs rerun triggered successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@PostMapping("/rerun-emacs")
|
||||||
|
public ResponseEntity<String> triggerEmacs() {
|
||||||
|
log.info("Triggering Emacs command");
|
||||||
|
return startCommand(emacsRunDirectory, emacsRunLogFile, emacsProcess);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Cancel Emacs rerun",
|
||||||
|
description = "Cancels a running Emacs rerun"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Emacs rerun cancelled successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@DeleteMapping("/rerun-emacs")
|
||||||
|
public ResponseEntity<String> cancelEmacs() {
|
||||||
|
return killProcess(emacsProcess, false, "emacs");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get Emacs rerun status",
|
||||||
|
description = "Returns the status of a running Emacs rerun"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Emacs rerun status retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/rerun-emacs/status")
|
||||||
|
public ResponseEntity<BuildStatus> getEmacsStatus() {
|
||||||
|
var s = buildRunState.emacsStatus();
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Stream Emacs rerun logs",
|
||||||
|
description = "Streams the logs of a running Emacs rerun"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Logs streamed successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/rerun-emacs/logs")
|
||||||
|
public SseEmitter streamEmacsLogs() {
|
||||||
|
return streamLogs(emacsRunLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
ADVENTUREOS COMMANDS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Free AdventureOS resources",
|
||||||
|
description = "Runs the configured resource cleanup command"
|
||||||
|
)
|
||||||
|
@PostMapping("/adventure/resources/free")
|
||||||
|
public ResponseEntity<String> freeAdventureResources() {
|
||||||
|
return startShellCommand(
|
||||||
|
BuildRunStateService.ADVENTURE_RESOURCES,
|
||||||
|
"resource cleanup",
|
||||||
|
resourceFreeDirectory,
|
||||||
|
resourceFreeLogFile,
|
||||||
|
resourceFreeCommand,
|
||||||
|
resourceProcess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/adventure/resources/free")
|
||||||
|
public ResponseEntity<String> cancelFreeAdventureResources() {
|
||||||
|
return killProcess(
|
||||||
|
resourceProcess,
|
||||||
|
BuildRunStateService.ADVENTURE_RESOURCES,
|
||||||
|
"resource cleanup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/adventure/resources/free/status")
|
||||||
|
public ResponseEntity<BuildStatus> getFreeAdventureResourcesStatus() {
|
||||||
|
var s = buildRunState.status(BuildRunStateService.ADVENTURE_RESOURCES);
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/adventure/resources/free/logs")
|
||||||
|
public SseEmitter streamFreeAdventureResourcesLogs() {
|
||||||
|
return streamLogs(resourceFreeLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Start Guacamole container",
|
||||||
|
description = "Starts the configured Apache Guacamole container"
|
||||||
|
)
|
||||||
|
@PostMapping("/guacamole/start")
|
||||||
|
public ResponseEntity<String> startGuacamole() {
|
||||||
|
return startCommand(
|
||||||
|
BuildRunStateService.GUACAMOLE_START,
|
||||||
|
"guacamole start",
|
||||||
|
guacamoleRunDirectory,
|
||||||
|
guacamoleStartLogFile,
|
||||||
|
List.of("docker", "start", guacamoleContainerName),
|
||||||
|
guacamoleStartProcess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/guacamole/start")
|
||||||
|
public ResponseEntity<String> cancelStartGuacamole() {
|
||||||
|
return killProcess(
|
||||||
|
guacamoleStartProcess,
|
||||||
|
BuildRunStateService.GUACAMOLE_START,
|
||||||
|
"guacamole start"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/guacamole/start/status")
|
||||||
|
public ResponseEntity<BuildStatus> getStartGuacamoleStatus() {
|
||||||
|
var s = buildRunState.status(BuildRunStateService.GUACAMOLE_START);
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/guacamole/start/logs")
|
||||||
|
public SseEmitter streamStartGuacamoleLogs() {
|
||||||
|
return streamLogs(guacamoleStartLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Stop Guacamole container",
|
||||||
|
description = "Stops the configured Apache Guacamole container"
|
||||||
|
)
|
||||||
|
@PostMapping("/guacamole/stop")
|
||||||
|
public ResponseEntity<String> stopGuacamole() {
|
||||||
|
return startCommand(
|
||||||
|
BuildRunStateService.GUACAMOLE_STOP,
|
||||||
|
"guacamole stop",
|
||||||
|
guacamoleRunDirectory,
|
||||||
|
guacamoleStopLogFile,
|
||||||
|
List.of("docker", "stop", guacamoleContainerName),
|
||||||
|
guacamoleStopProcess
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/guacamole/stop")
|
||||||
|
public ResponseEntity<String> cancelStopGuacamole() {
|
||||||
|
return killProcess(
|
||||||
|
guacamoleStopProcess,
|
||||||
|
BuildRunStateService.GUACAMOLE_STOP,
|
||||||
|
"guacamole stop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/guacamole/stop/status")
|
||||||
|
public ResponseEntity<BuildStatus> getStopGuacamoleStatus() {
|
||||||
|
var s = buildRunState.status(BuildRunStateService.GUACAMOLE_STOP);
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/guacamole/stop/logs")
|
||||||
|
public SseEmitter streamStopGuacamoleLogs() {
|
||||||
|
return streamLogs(guacamoleStopLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/nostalgia/production/status")
|
||||||
|
public ResponseEntity<BuildStatus> getNostalgiaProductionStatus() {
|
||||||
|
var s = buildRunState.status(BuildRunStateService.NOSTALGIA_PROD);
|
||||||
|
return ResponseEntity.ok(new BuildStatus(s.running(), s.lastRun(), s.lastExitCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/nostalgia/production/logs")
|
||||||
|
public SseEmitter streamNostalgiaProductionLogs() {
|
||||||
|
return streamLogs(nostalgiaRunLogFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
LAST RUN TIMESTAMPS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get last run timestamps",
|
||||||
|
description = "Returns the last run timestamps of all builds"
|
||||||
|
)
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Last run timestamps retrieved successfully"),
|
||||||
|
@ApiResponse(responseCode = "500", description = "Internal server error")
|
||||||
|
})
|
||||||
|
@GetMapping("/last-runs")
|
||||||
|
public ResponseEntity<Map<String, Object>> getLastRuns() {
|
||||||
|
return ResponseEntity.ok(buildRunState.lastRunsSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
CORE BUILD LOGIC
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
private ResponseEntity<String> startBuild(
|
||||||
|
String buildDir, String logPath,
|
||||||
|
boolean webBuild, AtomicReference<Process> processRef) {
|
||||||
|
|
||||||
|
boolean started = webBuild ? buildRunState.tryStartWeb() : buildRunState.tryStartEmacs();
|
||||||
|
if (!started) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body("Build already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File logFile = prepareLogFile(logPath);
|
||||||
|
ProcessBuilder pb = new ProcessBuilder("make");
|
||||||
|
pb.directory(Path.of(buildDir).toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
|
||||||
|
|
||||||
|
log.info("Starting build in {}", buildDir);
|
||||||
|
Process process = pb.start();
|
||||||
|
processRef.set(process);
|
||||||
|
if (webBuild) {
|
||||||
|
buildRunState.markWebStarted();
|
||||||
|
} else {
|
||||||
|
buildRunState.markEmacsStarted();
|
||||||
|
}
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
int exit = process.waitFor();
|
||||||
|
log.info("Build finished with exit code {}", exit);
|
||||||
|
if (webBuild) {
|
||||||
|
buildRunState.finishWeb(exit);
|
||||||
|
} else {
|
||||||
|
buildRunState.finishEmacs(exit);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.error("Build thread interrupted", e);
|
||||||
|
if (webBuild) {
|
||||||
|
buildRunState.clearWebRunning();
|
||||||
|
} else {
|
||||||
|
buildRunState.clearEmacsRunning();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processRef.set(null);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return ResponseEntity.ok("Build started");
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (webBuild) {
|
||||||
|
buildRunState.clearWebRunning();
|
||||||
|
} else {
|
||||||
|
buildRunState.clearEmacsRunning();
|
||||||
|
}
|
||||||
|
log.error("Build failed to start", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to start build: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> startCommand(
|
||||||
|
String runDir, String logPath,
|
||||||
|
AtomicReference<Process> processRef) {
|
||||||
|
|
||||||
|
if (!buildRunState.tryStartEmacs()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body("Command already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File logFile = prepareLogFile(logPath);
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
"bash", "-c",
|
||||||
|
"TERM=vt100 /usr/bin/timeout 10 /usr/bin/script -q -c \"emacs -nw\" /dev/null"
|
||||||
|
);
|
||||||
|
pb.directory(Path.of(runDir).toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
processRef.set(process);
|
||||||
|
buildRunState.markEmacsStarted();
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
int exit = process.waitFor();
|
||||||
|
buildRunState.finishEmacs(exit);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
buildRunState.clearEmacsRunning();
|
||||||
|
} finally {
|
||||||
|
processRef.set(null);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return ResponseEntity.ok("Command started");
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
buildRunState.clearEmacsRunning();
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to start command: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> startShellCommand(
|
||||||
|
String commandId,
|
||||||
|
String label,
|
||||||
|
String runDir,
|
||||||
|
String logPath,
|
||||||
|
String command,
|
||||||
|
AtomicReference<Process> processRef) {
|
||||||
|
|
||||||
|
return startCommand(
|
||||||
|
commandId,
|
||||||
|
label,
|
||||||
|
runDir,
|
||||||
|
logPath,
|
||||||
|
List.of("bash", "-lc", command),
|
||||||
|
processRef
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> startCommand(
|
||||||
|
String commandId,
|
||||||
|
String label,
|
||||||
|
String runDir,
|
||||||
|
String logPath,
|
||||||
|
List<String> command,
|
||||||
|
AtomicReference<Process> processRef) {
|
||||||
|
|
||||||
|
if (!buildRunState.tryStart(commandId)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(label + " already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File logFile = prepareLogFile(logPath);
|
||||||
|
appendToLog(logFile, "$ " + String.join(" ", command) + "\n\n");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(Path.of(runDir).toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
|
||||||
|
|
||||||
|
log.info("Starting {} in {}", label, runDir);
|
||||||
|
Process process = pb.start();
|
||||||
|
processRef.set(process);
|
||||||
|
buildRunState.markStarted(commandId);
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
int exit = process.waitFor();
|
||||||
|
log.info("{} finished with exit code {}", label, exit);
|
||||||
|
buildRunState.finish(commandId, exit);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
buildRunState.clearRunning(commandId);
|
||||||
|
} finally {
|
||||||
|
processRef.set(null);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(label + " started");
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
buildRunState.clearRunning(commandId);
|
||||||
|
log.error("{} failed to start", label, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body("Failed to start " + label + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> killProcess(
|
||||||
|
AtomicReference<Process> processRef,
|
||||||
|
boolean webBuild,
|
||||||
|
String label) {
|
||||||
|
|
||||||
|
Process p = processRef.get();
|
||||||
|
if (p == null || !p.isAlive()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No running " + label);
|
||||||
|
}
|
||||||
|
p.destroyForcibly();
|
||||||
|
if (webBuild) {
|
||||||
|
buildRunState.clearWebRunning();
|
||||||
|
} else {
|
||||||
|
buildRunState.clearEmacsRunning();
|
||||||
|
}
|
||||||
|
processRef.set(null);
|
||||||
|
log.info("Killed {}", label);
|
||||||
|
return ResponseEntity.ok("Killed " + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> killProcess(
|
||||||
|
AtomicReference<Process> processRef,
|
||||||
|
String commandId,
|
||||||
|
String label) {
|
||||||
|
|
||||||
|
Process p = processRef.get();
|
||||||
|
if (p == null || !p.isAlive()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No running " + label);
|
||||||
|
}
|
||||||
|
p.destroyForcibly();
|
||||||
|
buildRunState.clearRunning(commandId);
|
||||||
|
processRef.set(null);
|
||||||
|
log.info("Killed {}", label);
|
||||||
|
return ResponseEntity.ok("Killed " + label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
LOG STREAMING (SSE)
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tail a log file and stream each line as a named SSE "log" event,
|
||||||
|
* with ANSI colour sequences converted to HTML spans.
|
||||||
|
*
|
||||||
|
* <p>Why RandomAccessFile + manual byte accumulation instead of
|
||||||
|
* BufferedReader(FileInputStream):
|
||||||
|
* A FileInputStream reads to EOF and then readLine() returns null
|
||||||
|
* forever — it never observes bytes written after that point.
|
||||||
|
* RandomAccessFile.seek() lets us reposition to the current file
|
||||||
|
* pointer after each poll, so we correctly tail a growing file.
|
||||||
|
* We read raw bytes ourselves and decode with UTF-8 to avoid the
|
||||||
|
* ISO-8859-1 mangling that RandomAccessFile.readLine() does.
|
||||||
|
*/
|
||||||
|
private SseEmitter streamLogs(String logFilePath) {
|
||||||
|
SseEmitter emitter = new SseEmitter(0L);
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
final long IDLE_TIMEOUT_MS = 5_000;
|
||||||
|
final long POLL_INTERVAL_MS = 300;
|
||||||
|
|
||||||
|
long lastActivity = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// Wait up to 2 s for the log file to appear (build may not have
|
||||||
|
// created it yet when the client opens the SSE connection)
|
||||||
|
File logFile = new File(logFilePath);
|
||||||
|
long waitStart = System.currentTimeMillis();
|
||||||
|
while (!logFile.exists() && System.currentTimeMillis() - waitStart < 2_000) {
|
||||||
|
try { Thread.sleep(100); } catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(logFile, "r")) {
|
||||||
|
long pointer = 0;
|
||||||
|
// Line accumulator — holds a partial line across poll cycles
|
||||||
|
java.io.ByteArrayOutputStream lineBuf = new java.io.ByteArrayOutputStream(256);
|
||||||
|
|
||||||
|
while (!Thread.currentThread().isInterrupted()) {
|
||||||
|
long length = raf.length();
|
||||||
|
|
||||||
|
if (length > pointer) {
|
||||||
|
raf.seek(pointer);
|
||||||
|
// Read all newly available bytes
|
||||||
|
int b;
|
||||||
|
while ((b = raf.read()) != -1) {
|
||||||
|
if (b == '\n') {
|
||||||
|
// Decode the accumulated bytes as UTF-8
|
||||||
|
String line = lineBuf.toString(StandardCharsets.UTF_8);
|
||||||
|
lineBuf.reset();
|
||||||
|
lastActivity = System.currentTimeMillis();
|
||||||
|
String html = ansiToHtml(htmlEscape(line));
|
||||||
|
emitter.send(SseEmitter.event().name("log").data(html));
|
||||||
|
} else if (b != '\r') {
|
||||||
|
// Skip bare CR; accumulate everything else
|
||||||
|
lineBuf.write(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pointer = raf.getFilePointer();
|
||||||
|
} else {
|
||||||
|
// No new bytes — flush any partial line that has been
|
||||||
|
// sitting in the buffer for a while (e.g. a line that
|
||||||
|
// the process wrote without a trailing newline yet)
|
||||||
|
if (lineBuf.size() > 0
|
||||||
|
&& System.currentTimeMillis() - lastActivity > 1_000) {
|
||||||
|
String line = lineBuf.toString(StandardCharsets.UTF_8);
|
||||||
|
lineBuf.reset();
|
||||||
|
lastActivity = System.currentTimeMillis();
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("log")
|
||||||
|
.data(ansiToHtml(htmlEscape(line))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle timeout — build is done
|
||||||
|
if (System.currentTimeMillis() - lastActivity > IDLE_TIMEOUT_MS) {
|
||||||
|
emitter.send(SseEmitter.event()
|
||||||
|
.name("done").data("stream-end"));
|
||||||
|
emitter.complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread.sleep(POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Log stream ended: {}", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
emitter.complete();
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
ANSI → HTML CONVERSION
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ANSI SGR escape sequences in {@code text} to HTML {@code <span>}
|
||||||
|
* elements with CSS classes, and strip all other ANSI escape sequences.
|
||||||
|
*
|
||||||
|
* <p>The caller must HTML-escape the raw text <em>before</em> calling this
|
||||||
|
* method so that any {@code <} / {@code >} / {@code &} in the log output
|
||||||
|
* are already safe, and the {@code <span>} tags we insert here are the only
|
||||||
|
* real HTML in the result.
|
||||||
|
*
|
||||||
|
* <p>Example input: {@code "\033[32m\033[1m ✓ \033[0mBuild complete"}
|
||||||
|
* <p>Example output: {@code "<span class=\"ansi-green ansi-bold\"> ✓ </span>Build complete"}
|
||||||
|
*/
|
||||||
|
static String ansiToHtml(String text) {
|
||||||
|
// Fast path: no ESC → nothing to do
|
||||||
|
if (text.indexOf('\u001B') == -1) return text;
|
||||||
|
|
||||||
|
// Pattern for CSI SGR sequences only: ESC [ <numbers separated by ;> m
|
||||||
|
Pattern SGR = Pattern.compile("\u001B\\[([0-9;]*)m");
|
||||||
|
|
||||||
|
StringBuilder out = new StringBuilder(text.length() + 64);
|
||||||
|
int pos = 0;
|
||||||
|
boolean inSpan = false;
|
||||||
|
|
||||||
|
// We scan for SGR sequences; anything else (cursor movement etc.) is stripped
|
||||||
|
// by the final cleanup pass at the end.
|
||||||
|
Matcher m = SGR.matcher(text);
|
||||||
|
|
||||||
|
while (m.find()) {
|
||||||
|
// Append the literal text between last match and this one
|
||||||
|
out.append(text, pos, m.start());
|
||||||
|
pos = m.end();
|
||||||
|
|
||||||
|
String params = m.group(1); // e.g. "32", "1;32", "0", ""
|
||||||
|
|
||||||
|
// SGR 0 (or bare ESC[m) → close any open span
|
||||||
|
boolean isReset = params.isEmpty() || params.equals("0");
|
||||||
|
if (isReset) {
|
||||||
|
if (inSpan) {
|
||||||
|
out.append("</span>");
|
||||||
|
inSpan = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build CSS class list from the semicolon-separated codes
|
||||||
|
String[] codes = params.split(";");
|
||||||
|
StringBuilder classes = new StringBuilder();
|
||||||
|
for (String code : codes) {
|
||||||
|
String cls = SGR_CLASS.get(code);
|
||||||
|
if (cls != null) {
|
||||||
|
if (classes.length() > 0) classes.append(' ');
|
||||||
|
classes.append(cls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classes.length() > 0) {
|
||||||
|
if (inSpan) out.append("</span>");
|
||||||
|
out.append("<span class=\"").append(classes).append("\">");
|
||||||
|
inSpan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append any trailing text after the last match
|
||||||
|
out.append(text, pos, text.length());
|
||||||
|
if (inSpan) out.append("</span>");
|
||||||
|
|
||||||
|
// Strip any remaining non-SGR ANSI sequences (cursor movement, etc.)
|
||||||
|
return ANSI_ESCAPE.matcher(out).replaceAll("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML-special characters so log output is safe for innerHTML.
|
||||||
|
* Must be called BEFORE {@link #ansiToHtml} so the spans we insert aren't escaped.
|
||||||
|
*/
|
||||||
|
static String htmlEscape(String text) {
|
||||||
|
return text
|
||||||
|
.replace("&", "&") // must be first
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
HELPERS
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
private File prepareLogFile(String logPath) throws IOException {
|
||||||
|
File logFile = new File(logPath);
|
||||||
|
if (logFile.getParentFile() != null) logFile.getParentFile().mkdirs();
|
||||||
|
// Truncate/recreate cleanly
|
||||||
|
Files.deleteIfExists(logFile.toPath());
|
||||||
|
Files.createFile(logFile.toPath());
|
||||||
|
return logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendToLog(File logFile, String text) {
|
||||||
|
try (Writer fw = new OutputStreamWriter(
|
||||||
|
new FileOutputStream(logFile, true), StandardCharsets.UTF_8)) {
|
||||||
|
fw.write(text);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Could not append to log", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object nullSafe(Object val) {
|
||||||
|
return val != null ? val : "never";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
DTO
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
static class BuildStatus {
|
||||||
|
public boolean running;
|
||||||
|
public Instant lastRun;
|
||||||
|
public Integer lastExitCode;
|
||||||
|
|
||||||
|
public BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {
|
||||||
|
this.running = running;
|
||||||
|
this.lastRun = lastRun;
|
||||||
|
this.lastExitCode = lastExitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/main/java/org/zaine/app/controller/zone/TimesheetController.java
Executable file
77
src/main/java/org/zaine/app/controller/zone/TimesheetController.java
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
package org.zaine.app.controller.zone;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
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.RequestHeader;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import org.zaine.app.service.TimesheetService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
|
||||||
|
@RequestMapping("/api/timesheet")
|
||||||
|
|
||||||
|
@Tag(name = "Timesheet", description = "Timesheet year storage")
|
||||||
|
|
||||||
|
public class TimesheetController {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static final String API_KEY_HEADER = "X-Org-Api-Key";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private final TimesheetService timesheetService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public TimesheetController(TimesheetService timesheetService) {
|
||||||
|
|
||||||
|
this.timesheetService = timesheetService;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Operation(summary = "Get timesheet year")
|
||||||
|
|
||||||
|
@GetMapping("/{year}")
|
||||||
|
|
||||||
|
public JsonNode getYear(@PathVariable int year) {
|
||||||
|
|
||||||
|
return timesheetService.getYear(year);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
44
src/main/java/org/zaine/app/controller/zone/ZoneController.java
Executable file
44
src/main/java/org/zaine/app/controller/zone/ZoneController.java
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
package org.zaine.app.controller.zone;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
import org.zaine.app.service.ZoneManifestService;
|
||||||
|
import org.zaine.app.service.ZoneStatusService;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/zone")
|
||||||
|
@Tag(name = "Zone", description = "Zone dashboard manifest and integration status")
|
||||||
|
public class ZoneController {
|
||||||
|
|
||||||
|
private final ZoneManifestService manifestService;
|
||||||
|
private final ZoneStatusService statusService;
|
||||||
|
|
||||||
|
public ZoneController(ZoneManifestService manifestService, ZoneStatusService statusService) {
|
||||||
|
this.manifestService = manifestService;
|
||||||
|
this.statusService = statusService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get zone manifest", description = "Returns enriched manifest JSON for the dashboard")
|
||||||
|
@GetMapping("/manifest")
|
||||||
|
public Map<String, Object> manifest() {
|
||||||
|
try {
|
||||||
|
return manifestService.loadEnrichedManifest();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw ApplicationException.failure(ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get zone integration status", description = "Aggregated status for authoring, watcher, logs, timesheet")
|
||||||
|
@GetMapping("/status")
|
||||||
|
public Map<String, Object> status() {
|
||||||
|
return statusService.collectStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/main/java/org/zaine/app/dto/CalendarEventDTO.java
Executable file
15
src/main/java/org/zaine/app/dto/CalendarEventDTO.java
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record CalendarEventDTO(
|
||||||
|
Long id,
|
||||||
|
String externalId,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
String location,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
boolean allDay,
|
||||||
|
String source) {
|
||||||
|
}
|
||||||
12
src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java
Executable file
12
src/main/java/org/zaine/app/dto/CalendarSyncResultDTO.java
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record CalendarSyncResultDTO(
|
||||||
|
OffsetDateTime syncedAt,
|
||||||
|
int sourceCount,
|
||||||
|
int eventCount,
|
||||||
|
List<String> sources,
|
||||||
|
List<String> errors) {
|
||||||
|
}
|
||||||
18
src/main/java/org/zaine/app/dto/CompetenciesDTO.java
Executable file
18
src/main/java/org/zaine/app/dto/CompetenciesDTO.java
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
public class CompetenciesDTO {
|
||||||
|
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
public CompetenciesDTO() {
|
||||||
|
// REQUIRED: default constructor
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/org/zaine/app/dto/CreateCalendarEventDTO.java
Executable file
14
src/main/java/org/zaine/app/dto/CreateCalendarEventDTO.java
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record CreateCalendarEventDTO(
|
||||||
|
String externalId,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
String location,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
boolean allDay,
|
||||||
|
String source) {
|
||||||
|
}
|
||||||
50
src/main/java/org/zaine/app/dto/CreateCommentDTO.java
Executable file
50
src/main/java/org/zaine/app/dto/CreateCommentDTO.java
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public class CreateCommentDTO {
|
||||||
|
|
||||||
|
@JsonProperty("page_slug")
|
||||||
|
private String pageSlug;
|
||||||
|
|
||||||
|
private String author;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@JsonProperty("parent_id")
|
||||||
|
private Integer parentId;
|
||||||
|
|
||||||
|
public CreateCommentDTO() {}
|
||||||
|
|
||||||
|
public String getPageSlug() {
|
||||||
|
return pageSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPageSlug(String pageSlug) {
|
||||||
|
this.pageSlug = pageSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAuthor() {
|
||||||
|
return author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAuthor(String author) {
|
||||||
|
this.author = author;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getParentId() {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setParentId(Integer parentId) {
|
||||||
|
this.parentId = parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
Executable file
26
src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class MotalahSessionDTO {
|
||||||
|
|
||||||
|
private LocalDate date;
|
||||||
|
private Integer durationMinutes;
|
||||||
|
private List<Integer> bookIds;
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
// ── Getters & Setters ─────────────────────────────────
|
||||||
|
|
||||||
|
public LocalDate getDate() { return date; }
|
||||||
|
public void setDate(LocalDate date) { this.date = date; }
|
||||||
|
|
||||||
|
public Integer getDurationMinutes() { return durationMinutes; }
|
||||||
|
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
|
||||||
|
|
||||||
|
public List<Integer> getBookIds() { return bookIds; }
|
||||||
|
public void setBookIds(List<Integer> bookIds) { this.bookIds = bookIds; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
24
src/main/java/org/zaine/app/dto/RpgSaveDTO.java
Executable file
24
src/main/java/org/zaine/app/dto/RpgSaveDTO.java
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
public class RpgSaveDTO {
|
||||||
|
private String slot;
|
||||||
|
private JsonNode payload;
|
||||||
|
|
||||||
|
public String getSlot() {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSlot(String slot) {
|
||||||
|
this.slot = slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayload(JsonNode payload) {
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/java/org/zaine/app/dto/WirdEntryDTO.java
Executable file
20
src/main/java/org/zaine/app/dto/WirdEntryDTO.java
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
package org.zaine.app.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
public class WirdEntryDTO {
|
||||||
|
private String wirdType;
|
||||||
|
private LocalDate date;
|
||||||
|
private BigDecimal value;
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
public String getWirdType() { return wirdType; }
|
||||||
|
public void setWirdType(String t) { this.wirdType = t; }
|
||||||
|
public LocalDate getDate() { return date; }
|
||||||
|
public void setDate(LocalDate d) { this.date = d; }
|
||||||
|
public BigDecimal getValue() { return value; }
|
||||||
|
public void setValue(BigDecimal v) { this.value = v; }
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String n) { this.notes = n; }
|
||||||
|
}
|
||||||
59
src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java
Executable file
59
src/main/java/org/zaine/app/exception/GlobalExceptionHandler.java
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
package org.zaine.app.exception;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(NoHandlerFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleNotFound(HttpServletRequest request) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
|
||||||
|
Map.of("message", "Could not find resource with URL: " + request.getRequestURI())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ApplicationException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleApplication(
|
||||||
|
ApplicationException ex,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
HttpStatus status = switch (ex.kind()) {
|
||||||
|
case BAD_REQUEST -> HttpStatus.BAD_REQUEST;
|
||||||
|
case NOT_FOUND -> HttpStatus.NOT_FOUND;
|
||||||
|
case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED;
|
||||||
|
case PAYLOAD_TOO_LARGE -> HttpStatus.PAYLOAD_TOO_LARGE;
|
||||||
|
case FAILURE -> HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
};
|
||||||
|
if (status.is4xxClientError()) {
|
||||||
|
log.debug("Request rejected: status={}, path={}, reason={}", status.value(), request.getRequestURI(), ex.getMessage());
|
||||||
|
} else {
|
||||||
|
log.warn("Application operation failed: path={}, reason={}", request.getRequestURI(), ex.getMessage());
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(status).body(Map.of("message", ex.getMessage(), "path", request.getRequestURI()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DataAccessException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleDataAccess(
|
||||||
|
DataAccessException ex,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
log.error("Database operation failed for path {}", request.getRequestURI(), ex);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
|
||||||
|
Map.of(
|
||||||
|
"message", "Database error",
|
||||||
|
"path", request.getRequestURI()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/java/org/zaine/app/model/CalendarEvent.java
Executable file
83
src/main/java/org/zaine/app/model/CalendarEvent.java
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.PreUpdate;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "calendar_events")
|
||||||
|
public class CalendarEvent {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "external_id", unique = true)
|
||||||
|
private String externalId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
@Column(name = "starts_at", nullable = false)
|
||||||
|
private OffsetDateTime startsAt;
|
||||||
|
|
||||||
|
@Column(name = "ends_at", nullable = false)
|
||||||
|
private OffsetDateTime endsAt;
|
||||||
|
|
||||||
|
@Column(name = "all_day", nullable = false)
|
||||||
|
private boolean allDay;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String source = "manual";
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onInsert() {
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
void onUpdate() {
|
||||||
|
this.updatedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getExternalId() { return externalId; }
|
||||||
|
public void setExternalId(String externalId) { this.externalId = externalId; }
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
public void setLocation(String location) { this.location = location; }
|
||||||
|
public OffsetDateTime getStartsAt() { return startsAt; }
|
||||||
|
public void setStartsAt(OffsetDateTime startsAt) { this.startsAt = startsAt; }
|
||||||
|
public OffsetDateTime getEndsAt() { return endsAt; }
|
||||||
|
public void setEndsAt(OffsetDateTime endsAt) { this.endsAt = endsAt; }
|
||||||
|
public boolean isAllDay() { return allDay; }
|
||||||
|
public void setAllDay(boolean allDay) { this.allDay = allDay; }
|
||||||
|
public String getSource() { return source; }
|
||||||
|
public void setSource(String source) { this.source = source; }
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
78
src/main/java/org/zaine/app/model/Comments.java
Executable file
78
src/main/java/org/zaine/app/model/Comments.java
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "comments")
|
||||||
|
public class Comments {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "id")
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(name = "page_slug")
|
||||||
|
private String pageSlug;
|
||||||
|
|
||||||
|
@Column(name="author")
|
||||||
|
private String author;
|
||||||
|
|
||||||
|
@Column(name="content")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@JsonProperty("created_at")
|
||||||
|
@Column(name="created_at")
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@JsonProperty("parent_id")
|
||||||
|
@Column(name="parent_id")
|
||||||
|
private Integer parentId;
|
||||||
|
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
public void setId(Integer id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
public String getPageSlug() {
|
||||||
|
return pageSlug;
|
||||||
|
}
|
||||||
|
public void setPageSlug(String pageSlug) {
|
||||||
|
this.pageSlug = pageSlug;
|
||||||
|
}
|
||||||
|
public String getAuthor() {
|
||||||
|
return author;
|
||||||
|
}
|
||||||
|
public void setAuthor(String author) {
|
||||||
|
this.author = author;
|
||||||
|
}
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
public Integer getParentId() {
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
public void setParentId(Integer parentId) {
|
||||||
|
this.parentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
src/main/java/org/zaine/app/model/Competencies.java
Executable file
65
src/main/java/org/zaine/app/model/Competencies.java
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "competencies")
|
||||||
|
public class Competencies {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name="id")
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(name="title")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name="state")
|
||||||
|
private String state;
|
||||||
|
|
||||||
|
@Column(name="created_at")
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name="group_name")
|
||||||
|
private String group;
|
||||||
|
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setState(String state) {
|
||||||
|
this.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getGroup() {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGroup(String group) {
|
||||||
|
this.group = group;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
42
src/main/java/org/zaine/app/model/IntegerListConverter.java
Executable file
42
src/main/java/org/zaine/app/model/IntegerListConverter.java
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts between a Java List<Integer> and a PostgreSQL-compatible
|
||||||
|
* comma-separated string that Hibernate stores in the integer[] column.
|
||||||
|
*
|
||||||
|
* Because standard JPA/Hibernate doesn't natively map PostgreSQL arrays,
|
||||||
|
* we serialise as "{1,2,3}" which is exactly what Postgres expects for
|
||||||
|
* an array literal when sent as a string parameter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class IntegerListConverter implements AttributeConverter<List<Integer>, String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(List<Integer> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||||
|
return "{" + attribute.stream()
|
||||||
|
.map(String::valueOf)
|
||||||
|
.collect(Collectors.joining(",")) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Integer> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank() || dbData.equals("{}")) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
// Strip surrounding braces: "{1,2,3}" → "1,2,3"
|
||||||
|
String inner = dbData.replaceAll("[{}]", "").trim();
|
||||||
|
if (inner.isEmpty()) return Collections.emptyList();
|
||||||
|
return Arrays.stream(inner.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.map(Integer::parseInt)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/java/org/zaine/app/model/MotalahSession.java
Executable file
62
src/main/java/org/zaine/app/model/MotalahSession.java
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "motalah_sessions")
|
||||||
|
public class MotalahSession {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
@Column(name = "duration_minutes", nullable = false)
|
||||||
|
private Integer durationMinutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps directly to PostgreSQL integer[].
|
||||||
|
* @JdbcTypeCode(SqlTypes.ARRAY) tells Hibernate to use the
|
||||||
|
* native JDBC Array binding — no custom converter needed.
|
||||||
|
*/
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "book_ids", columnDefinition = "integer[]")
|
||||||
|
private List<Integer> bookIds;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onInsert() {
|
||||||
|
this.createdAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Getters & Setters ─────────────────────────────────
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
|
||||||
|
public LocalDate getDate() { return date; }
|
||||||
|
public void setDate(LocalDate date) { this.date = date; }
|
||||||
|
|
||||||
|
public Integer getDurationMinutes() { return durationMinutes; }
|
||||||
|
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
|
||||||
|
|
||||||
|
public List<Integer> getBookIds() { return bookIds; }
|
||||||
|
public void setBookIds(List<Integer> bookIds) { this.bookIds = bookIds; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package org.zaine.app.model;
|
|
||||||
|
|
||||||
import java.sql.Date;
|
|
||||||
|
|
||||||
public class Notes {
|
|
||||||
private String content;
|
|
||||||
private String authorName;
|
|
||||||
private Date createdAt;
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
public Notes() {
|
|
||||||
this.createdAt = new Date(System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getContent() {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setContent(String content) {
|
|
||||||
this.content = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getAuthorName() {
|
|
||||||
return authorName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAuthorName(String authorName) {
|
|
||||||
this.authorName = authorName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Date getCreatedAt() {
|
|
||||||
return createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setId(Long id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
51
src/main/java/org/zaine/app/model/TimesheetYear.java
Executable file
51
src/main/java/org/zaine/app/model/TimesheetYear.java
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "timesheet_year")
|
||||||
|
public class TimesheetYear {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "year")
|
||||||
|
private Integer year;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "payload", nullable = false, columnDefinition = "jsonb")
|
||||||
|
private String payload;
|
||||||
|
|
||||||
|
@Column(name = "saved_at", nullable = false)
|
||||||
|
private Instant savedAt = Instant.now();
|
||||||
|
|
||||||
|
public Integer getYear() {
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setYear(Integer year) {
|
||||||
|
this.year = year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPayload() {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayload(String payload) {
|
||||||
|
this.payload = payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getSavedAt() {
|
||||||
|
return savedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSavedAt(Instant savedAt) {
|
||||||
|
this.savedAt = savedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/org/zaine/app/model/WirdEntry.java
Executable file
43
src/main/java/org/zaine/app/model/WirdEntry.java
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
package org.zaine.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wird_entries")
|
||||||
|
public class WirdEntry {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "wird_type", nullable = false)
|
||||||
|
private String wirdType;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal value;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "created_at", insertable = false, updatable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
// ── Getters & Setters ─────────────────────────────────── //
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getWirdType() { return wirdType; }
|
||||||
|
public void setWirdType(String t) { this.wirdType = t; }
|
||||||
|
public LocalDate getDate() { return date; }
|
||||||
|
public void setDate(LocalDate d) { this.date = d; }
|
||||||
|
public BigDecimal getValue() { return value; }
|
||||||
|
public void setValue(BigDecimal v) { this.value = v; }
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String n) { this.notes = n; }
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
}
|
||||||
50
src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java
Executable file
50
src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
package org.zaine.app.notes.adapter.in.web;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
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.RestController;
|
||||||
|
import org.zaine.app.notes.application.port.in.NotesUseCase;
|
||||||
|
import org.zaine.app.notes.domain.Note;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/notes")
|
||||||
|
@Tag(name = "Notes", description = "Operations related to notes")
|
||||||
|
public class NotesController {
|
||||||
|
private final NotesUseCase notes;
|
||||||
|
|
||||||
|
public NotesController(NotesUseCase notes) {
|
||||||
|
this.notes = notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Get all notes")
|
||||||
|
@GetMapping
|
||||||
|
public List<NoteResponse> getAllNotes() {
|
||||||
|
return notes.getAll().stream().map(NoteResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Create a note object")
|
||||||
|
@PostMapping(consumes = "application/json")
|
||||||
|
public NoteResponse createNote(@RequestBody CreateNoteRequest request) {
|
||||||
|
return NoteResponse.from(notes.create(request.content(), request.authorName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateNoteRequest(@JsonProperty("author_name") String authorName, String content) {}
|
||||||
|
|
||||||
|
public record NoteResponse(
|
||||||
|
UUID id,
|
||||||
|
String content,
|
||||||
|
@JsonProperty("author_name") String authorName,
|
||||||
|
@JsonProperty("created_at") Instant createdAt) {
|
||||||
|
static NoteResponse from(Note note) {
|
||||||
|
return new NoteResponse(note.id(), note.content(), note.authorName(), note.createdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java
Executable file
40
src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
package org.zaine.app.notes.adapter.out.persistence;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "public_notes")
|
||||||
|
class NoteJpaEntity {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "content")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "author_name")
|
||||||
|
private String authorName;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
protected NoteJpaEntity() {}
|
||||||
|
|
||||||
|
NoteJpaEntity(String content, String authorName) {
|
||||||
|
this.content = content;
|
||||||
|
this.authorName = authorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID id() { return id; }
|
||||||
|
String content() { return content; }
|
||||||
|
String authorName() { return authorName; }
|
||||||
|
Instant createdAt() { return createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.zaine.app.notes.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.zaine.app.notes.application.port.out.NoteRepositoryPort;
|
||||||
|
import org.zaine.app.notes.domain.Note;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class NotePersistenceAdapter implements NoteRepositoryPort {
|
||||||
|
private final SpringDataNoteRepository repository;
|
||||||
|
|
||||||
|
NotePersistenceAdapter(SpringDataNoteRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Note> findAll() {
|
||||||
|
return repository.findAll().stream().map(NotePersistenceAdapter::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Note save(Note note) {
|
||||||
|
return toDomain(repository.save(new NoteJpaEntity(note.content(), note.authorName())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Note toDomain(NoteJpaEntity entity) {
|
||||||
|
return new Note(entity.id(), entity.content(), entity.authorName(), entity.createdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.zaine.app.notes.adapter.out.persistence;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
interface SpringDataNoteRepository extends JpaRepository<NoteJpaEntity, UUID> {}
|
||||||
9
src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java
Executable file
9
src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
package org.zaine.app.notes.application.port.in;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.zaine.app.notes.domain.Note;
|
||||||
|
|
||||||
|
public interface NotesUseCase {
|
||||||
|
List<Note> getAll();
|
||||||
|
Note create(String content, String authorName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.zaine.app.notes.application.port.out;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.zaine.app.notes.domain.Note;
|
||||||
|
|
||||||
|
public interface NoteRepositoryPort {
|
||||||
|
List<Note> findAll();
|
||||||
|
Note save(Note note);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.zaine.app.notes.application.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.notes.application.port.in.NotesUseCase;
|
||||||
|
import org.zaine.app.notes.application.port.out.NoteRepositoryPort;
|
||||||
|
import org.zaine.app.notes.domain.Note;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NotesApplicationService implements NotesUseCase {
|
||||||
|
private final NoteRepositoryPort repository;
|
||||||
|
|
||||||
|
public NotesApplicationService(NoteRepositoryPort repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Note> getAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Note create(String content, String authorName) {
|
||||||
|
return repository.save(Note.create(content, authorName));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main/java/org/zaine/app/notes/domain/Note.java
Executable file
10
src/main/java/org/zaine/app/notes/domain/Note.java
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
package org.zaine.app.notes.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record Note(UUID id, String content, String authorName, Instant createdAt) {
|
||||||
|
public static Note create(String content, String authorName) {
|
||||||
|
return new Note(null, content, authorName, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/main/java/org/zaine/app/repositories/CalendarEventRepository.java
Executable file
15
src/main/java/org/zaine/app/repositories/CalendarEventRepository.java
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.zaine.app.model.CalendarEvent;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CalendarEventRepository extends JpaRepository<CalendarEvent, Long> {
|
||||||
|
List<CalendarEvent> findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(
|
||||||
|
OffsetDateTime rangeEnd,
|
||||||
|
OffsetDateTime rangeStart);
|
||||||
|
|
||||||
|
void deleteBySource(String source);
|
||||||
|
}
|
||||||
29
src/main/java/org/zaine/app/repositories/CommentsRepository.java
Executable file
29
src/main/java/org/zaine/app/repositories/CommentsRepository.java
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.zaine.app.model.Comments;
|
||||||
|
|
||||||
|
public interface CommentsRepository extends JpaRepository<Comments, Integer> {
|
||||||
|
@Query(value = """
|
||||||
|
WITH RECURSIVE comment_tree AS (
|
||||||
|
SELECT id, parent_id, author, content, created_at, page_slug
|
||||||
|
FROM comments
|
||||||
|
WHERE id = :commentId
|
||||||
|
AND is_deleted = false
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT c.id, c.parent_id, c.author, c.content, c.created_at, c.page_slug
|
||||||
|
FROM comments c
|
||||||
|
JOIN comment_tree ct ON c.parent_id = ct.id
|
||||||
|
WHERE c.is_deleted = false
|
||||||
|
)
|
||||||
|
SELECT * FROM comment_tree
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Comments> findCommentThread(@Param("commentId") Integer commentId);
|
||||||
|
}
|
||||||
10
src/main/java/org/zaine/app/repositories/CompetenciesRepository.java
Executable file
10
src/main/java/org/zaine/app/repositories/CompetenciesRepository.java
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.zaine.app.model.Competencies;
|
||||||
|
|
||||||
|
|
||||||
|
public interface CompetenciesRepository extends JpaRepository<Competencies, Integer> {
|
||||||
|
List<Competencies> findByGroup(String group);
|
||||||
|
}
|
||||||
25
src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java
Executable file
25
src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.zaine.app.model.MotalahSession;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface MotalahSessionRepository extends JpaRepository<MotalahSession, Long> {
|
||||||
|
|
||||||
|
/** All sessions newest first — used by the main list and heatmap. */
|
||||||
|
List<MotalahSession> findAllByOrderByDateDesc();
|
||||||
|
|
||||||
|
/** Sessions within a date range — used for month totals / trend chart. */
|
||||||
|
@Query("SELECT s FROM MotalahSession s WHERE s.date >= :from AND s.date <= :to ORDER BY s.date DESC")
|
||||||
|
List<MotalahSession> findInRange(
|
||||||
|
@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Today's sessions. */
|
||||||
|
List<MotalahSession> findByDate(LocalDate date);
|
||||||
|
}
|
||||||
7
src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java
Executable file
7
src/main/java/org/zaine/app/repositories/TimesheetYearRepository.java
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.zaine.app.model.TimesheetYear;
|
||||||
|
|
||||||
|
public interface TimesheetYearRepository extends JpaRepository<TimesheetYear, Integer> {
|
||||||
|
}
|
||||||
37
src/main/java/org/zaine/app/repositories/WirdEntryRepository.java
Executable file
37
src/main/java/org/zaine/app/repositories/WirdEntryRepository.java
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
package org.zaine.app.repositories;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.zaine.app.model.WirdEntry;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WirdEntryRepository extends JpaRepository<WirdEntry, Long> {
|
||||||
|
|
||||||
|
// All entries ordered newest first (for history table)
|
||||||
|
List<WirdEntry> findAllByOrderByDateDescCreatedAtDesc();
|
||||||
|
|
||||||
|
// Entries for a specific date (today's cards)
|
||||||
|
List<WirdEntry> findByDateOrderByCreatedAtDesc(LocalDate date);
|
||||||
|
|
||||||
|
// Entries within a date range for a specific wird type (trend chart)
|
||||||
|
@Query("SELECT e FROM WirdEntry e WHERE e.wirdType = :type AND e.date BETWEEN :from AND :to ORDER BY e.date ASC")
|
||||||
|
List<WirdEntry> findByTypeAndDateRange(
|
||||||
|
@Param("type") String type,
|
||||||
|
@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to
|
||||||
|
);
|
||||||
|
|
||||||
|
// All entries for a date range (bulk fetch for chart, avoids N+1)
|
||||||
|
@Query("SELECT e FROM WirdEntry e WHERE e.date BETWEEN :from AND :to ORDER BY e.date ASC, e.wirdType ASC")
|
||||||
|
List<WirdEntry> findByDateRange(
|
||||||
|
@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to
|
||||||
|
);
|
||||||
|
|
||||||
|
List<WirdEntry> findByWirdTypeOrderByDateDesc(String wirdType);
|
||||||
|
}
|
||||||
11
src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java
Executable file
11
src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
package org.zaine.app.resource;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
@ResponseStatus(HttpStatus.PRECONDITION_FAILED)
|
||||||
|
public class ResourceLoaderConflictException extends RuntimeException {
|
||||||
|
public ResourceLoaderConflictException() {
|
||||||
|
super("The Resource Loader library changed on another device.");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/org/zaine/app/resource/ResourceLoaderController.java
Executable file
65
src/main/java/org/zaine/app/resource/ResourceLoaderController.java
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
package org.zaine.app.resource;
|
||||||
|
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/resource-loader")
|
||||||
|
@Tag(name = "Resource Loader", description = "Cross-device Resource Loader metadata and thumbnails")
|
||||||
|
public class ResourceLoaderController {
|
||||||
|
private final ResourceLoaderService service;
|
||||||
|
|
||||||
|
public ResourceLoaderController(ResourceLoaderService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<JsonNode> loadLibrary() {
|
||||||
|
ResourceLoaderService.LibrarySnapshot snapshot = service.loadLibrary();
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.eTag(snapshot.etag())
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.body(snapshot.library());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ResponseEntity<JsonNode> saveLibrary(
|
||||||
|
@RequestBody JsonNode library,
|
||||||
|
@RequestHeader(value = "If-Match", required = false) String ifMatch) {
|
||||||
|
ResourceLoaderService.LibrarySnapshot snapshot = service.saveLibrary(library, ifMatch);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.eTag(snapshot.etag())
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.body(snapshot.library());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/thumbnails/{id}")
|
||||||
|
public ResponseEntity<byte[]> loadThumbnail(@PathVariable String id) {
|
||||||
|
ResourceLoaderService.ThumbnailSnapshot thumbnail = service.loadThumbnail(id);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.parseMediaType(thumbnail.contentType()))
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.body(thumbnail.bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/thumbnails/{id}")
|
||||||
|
public ResponseEntity<Void> saveThumbnail(
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestHeader("Content-Type") String contentType,
|
||||||
|
@RequestBody byte[] bytes) {
|
||||||
|
service.saveThumbnail(id, contentType, bytes);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/main/java/org/zaine/app/resource/ResourceLoaderService.java
Executable file
185
src/main/java/org/zaine/app/resource/ResourceLoaderService.java
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
package org.zaine.app.resource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ResourceLoaderService {
|
||||||
|
private static final String DEFAULT_STORAGE_DIRECTORY =
|
||||||
|
"/home/zaine/master-folder/org-platform/org_backend/data/resource-loader";
|
||||||
|
private static final Set<String> THUMBNAIL_TYPES = Set.of("image/jpeg", "image/png", "image/webp");
|
||||||
|
private static final long MAX_LIBRARY_BYTES = 10L * 1024 * 1024;
|
||||||
|
private static final long MAX_THUMBNAIL_BYTES = 5L * 1024 * 1024;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Path storageDirectory;
|
||||||
|
private final Path libraryPath;
|
||||||
|
private final Path thumbnailDirectory;
|
||||||
|
|
||||||
|
public ResourceLoaderService(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${resource-loader.storage-dir:" + DEFAULT_STORAGE_DIRECTORY + "}") String storageDirectory) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.storageDirectory = Path.of(storageDirectory);
|
||||||
|
this.libraryPath = this.storageDirectory.resolve("library.json");
|
||||||
|
this.thumbnailDirectory = this.storageDirectory.resolve("thumbnails");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized LibrarySnapshot loadLibrary() {
|
||||||
|
if (!Files.isRegularFile(libraryPath)) {
|
||||||
|
throw ApplicationException.notFound("Resource Loader library has not been initialised.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] bytes = Files.readAllBytes(libraryPath);
|
||||||
|
JsonNode library = objectMapper.readTree(bytes);
|
||||||
|
validateLibrary(library);
|
||||||
|
return new LibrarySnapshot(library, etag(bytes));
|
||||||
|
} catch (ApplicationException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApplicationException.failure("Failed to read the Resource Loader library.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized LibrarySnapshot saveLibrary(JsonNode library, String ifMatch) {
|
||||||
|
validateLibrary(library);
|
||||||
|
try {
|
||||||
|
if (Files.isRegularFile(libraryPath) && ifMatch != null && !ifMatch.isBlank()) {
|
||||||
|
String currentEtag = etag(Files.readAllBytes(libraryPath));
|
||||||
|
if (!currentEtag.equals(ifMatch.trim())) {
|
||||||
|
throw new ResourceLoaderConflictException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(library);
|
||||||
|
if (bytes.length > MAX_LIBRARY_BYTES) {
|
||||||
|
throw ApplicationException.payloadTooLarge("Resource Loader metadata exceeds 10 MB.");
|
||||||
|
}
|
||||||
|
Files.createDirectories(storageDirectory);
|
||||||
|
atomicWrite(libraryPath, bytes);
|
||||||
|
return new LibrarySnapshot(objectMapper.readTree(bytes), etag(bytes));
|
||||||
|
} catch (ResourceLoaderConflictException | ApplicationException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApplicationException.failure("Failed to save the Resource Loader library.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void saveThumbnail(String id, String contentType, byte[] bytes) {
|
||||||
|
validateId(id);
|
||||||
|
String normalisedType = contentType == null ? "" : contentType.split(";", 2)[0].trim().toLowerCase();
|
||||||
|
if (!THUMBNAIL_TYPES.contains(normalisedType)) {
|
||||||
|
throw ApplicationException.badRequest("Thumbnail must be JPEG, PNG, or WebP.");
|
||||||
|
}
|
||||||
|
if (bytes == null || bytes.length == 0) {
|
||||||
|
throw ApplicationException.badRequest("Thumbnail is empty.");
|
||||||
|
}
|
||||||
|
if (bytes.length > MAX_THUMBNAIL_BYTES) {
|
||||||
|
throw ApplicationException.payloadTooLarge("Thumbnail exceeds 5 MB.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.createDirectories(thumbnailDirectory);
|
||||||
|
atomicWrite(thumbnailPath(id), bytes);
|
||||||
|
atomicWrite(thumbnailTypePath(id), normalisedType.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApplicationException.failure("Failed to save the Resource Loader thumbnail.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThumbnailSnapshot loadThumbnail(String id) {
|
||||||
|
validateId(id);
|
||||||
|
Path image = thumbnailPath(id);
|
||||||
|
Path type = thumbnailTypePath(id);
|
||||||
|
if (!Files.isRegularFile(image) || !Files.isRegularFile(type)) {
|
||||||
|
throw ApplicationException.notFound("Resource Loader thumbnail was not found.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ThumbnailSnapshot(Files.readAllBytes(image), Files.readString(type).trim());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw ApplicationException.failure("Failed to read the Resource Loader thumbnail.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateLibrary(JsonNode library) {
|
||||||
|
if (library == null || !library.isObject()) {
|
||||||
|
throw ApplicationException.badRequest("Resource Loader library must be a JSON object.");
|
||||||
|
}
|
||||||
|
if (!library.path("schemaVersion").isInt() || library.path("schemaVersion").intValue() != 1) {
|
||||||
|
throw ApplicationException.badRequest("Unsupported Resource Loader schemaVersion.");
|
||||||
|
}
|
||||||
|
JsonNode vaults = library.path("vaults");
|
||||||
|
if (!vaults.isArray() || vaults.isEmpty()) {
|
||||||
|
throw ApplicationException.badRequest("Resource Loader library must contain at least one vault.");
|
||||||
|
}
|
||||||
|
for (JsonNode vault : vaults) {
|
||||||
|
if (!nonBlank(vault, "id") || !nonBlank(vault, "name") || !vault.path("folders").isArray()
|
||||||
|
|| !vault.path("resources").isArray()) {
|
||||||
|
throw ApplicationException.badRequest("Every vault requires an id, name, folders, and resources.");
|
||||||
|
}
|
||||||
|
for (JsonNode resource : vault.path("resources")) {
|
||||||
|
if (!nonBlank(resource, "id") || !nonBlank(resource, "title") || !resource.path("sessions").isArray()) {
|
||||||
|
throw ApplicationException.badRequest("Every resource requires an id, title, and sessions array.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean nonBlank(JsonNode node, String field) {
|
||||||
|
return node.path(field).isTextual() && !node.path(field).textValue().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateId(String id) {
|
||||||
|
if (id == null || !id.matches("[A-Za-z0-9_-]{1,128}")) {
|
||||||
|
throw ApplicationException.badRequest("Invalid Resource Loader thumbnail id.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path thumbnailPath(String id) {
|
||||||
|
return thumbnailDirectory.resolve(id + ".bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path thumbnailTypePath(String id) {
|
||||||
|
return thumbnailDirectory.resolve(id + ".type");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void atomicWrite(Path target, byte[] bytes) throws IOException {
|
||||||
|
Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp");
|
||||||
|
try {
|
||||||
|
Files.write(temporary, bytes);
|
||||||
|
try {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (AtomicMoveNotSupportedException e) {
|
||||||
|
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String etag(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
|
||||||
|
return "\"" + HexFormat.of().formatHex(digest) + "\"";
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LibrarySnapshot(JsonNode library, String etag) {}
|
||||||
|
public record ThumbnailSnapshot(byte[] bytes, String contentType) {}
|
||||||
|
}
|
||||||
57
src/main/java/org/zaine/app/security/JwtUtil.java
Executable file
57
src/main/java/org/zaine/app/security/JwtUtil.java
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
package org.zaine.app.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
private final String secret;
|
||||||
|
private final long expirationMs;
|
||||||
|
|
||||||
|
public JwtUtil(
|
||||||
|
@Value("${jwt.secret}") String secret,
|
||||||
|
@Value("${jwt.expiration-ms:86400000}") long expirationMs) {
|
||||||
|
this.secret = secret;
|
||||||
|
this.expirationMs = expirationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Key getSigningKey() {
|
||||||
|
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String username) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(username)
|
||||||
|
.setIssuedAt(new Date())
|
||||||
|
.setExpiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||||
|
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractUsername(String token) {
|
||||||
|
return parseClaims(token).getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
try {
|
||||||
|
parseClaims(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims parseClaims(String token) {
|
||||||
|
return Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/java/org/zaine/app/security/SecurityConfig.java
Executable file
62
src/main/java/org/zaine/app/security/SecurityConfig.java
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
// src/main/java/org/zaine/app/security/SecurityConfig.java
|
||||||
|
package org.zaine.app.security;
|
||||||
|
|
||||||
|
import org.zaine.app.user.UserDetailsServiceImpl;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.*;
|
||||||
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
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.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final UserDetailsServiceImpl userDetailsService;
|
||||||
|
|
||||||
|
public SecurityConfig(UserDetailsServiceImpl userDetailsService) {
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(
|
||||||
|
"/api/auth/**",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html"
|
||||||
|
).permitAll()
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DaoAuthenticationProvider authenticationProvider() {
|
||||||
|
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
|
||||||
|
provider.setUserDetailsService(userDetailsService);
|
||||||
|
provider.setPasswordEncoder(passwordEncoder());
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/main/java/org/zaine/app/service/BuildRunStateService.java
Executable file
143
src/main/java/org/zaine/app/service/BuildRunStateService.java
Executable file
@@ -0,0 +1,143 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class BuildRunStateService {
|
||||||
|
|
||||||
|
public static final String WEB = "org_web";
|
||||||
|
public static final String EMACS = "emacs";
|
||||||
|
public static final String ADVENTURE_RESOURCES = "adventure_resources";
|
||||||
|
public static final String GUACAMOLE_START = "guacamole_start";
|
||||||
|
public static final String GUACAMOLE_STOP = "guacamole_stop";
|
||||||
|
public static final String NOSTALGIA_PROD = "nostalgia_prod";
|
||||||
|
|
||||||
|
private final Map<String, CommandState> states = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public BuildRunStateService() {
|
||||||
|
for (String id : new String[] {
|
||||||
|
WEB,
|
||||||
|
EMACS,
|
||||||
|
ADVENTURE_RESOURCES,
|
||||||
|
GUACAMOLE_START,
|
||||||
|
GUACAMOLE_STOP,
|
||||||
|
NOSTALGIA_PROD
|
||||||
|
}) {
|
||||||
|
states.put(id, new CommandState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryStartWeb() {
|
||||||
|
return tryStart(WEB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markWebStarted() {
|
||||||
|
markStarted(WEB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishWeb(int exitCode) {
|
||||||
|
finish(WEB, exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearWebRunning() {
|
||||||
|
clearRunning(WEB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isWebRunning() {
|
||||||
|
return status(WEB).running();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryStartEmacs() {
|
||||||
|
return tryStart(EMACS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markEmacsStarted() {
|
||||||
|
markStarted(EMACS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishEmacs(int exitCode) {
|
||||||
|
finish(EMACS, exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearEmacsRunning() {
|
||||||
|
clearRunning(EMACS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmacsRunning() {
|
||||||
|
return status(EMACS).running();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryStart(String id) {
|
||||||
|
return state(id).running.compareAndSet(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markStarted(String id) {
|
||||||
|
state(id).lastRun = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finish(String id, int exitCode) {
|
||||||
|
CommandState state = state(id);
|
||||||
|
state.lastRun = Instant.now();
|
||||||
|
state.lastExitCode = exitCode;
|
||||||
|
state.running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearRunning(String id) {
|
||||||
|
state(id).running.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> lastRunsSnapshot() {
|
||||||
|
Map<String, Object> runs = new LinkedHashMap<>();
|
||||||
|
for (String id : new String[] {
|
||||||
|
WEB,
|
||||||
|
EMACS,
|
||||||
|
ADVENTURE_RESOURCES,
|
||||||
|
GUACAMOLE_START,
|
||||||
|
GUACAMOLE_STOP,
|
||||||
|
NOSTALGIA_PROD
|
||||||
|
}) {
|
||||||
|
CommandState state = state(id);
|
||||||
|
runs.put(id, Map.of(
|
||||||
|
"lastRun", nullSafe(state.lastRun),
|
||||||
|
"exitCode", nullSafe(state.lastExitCode),
|
||||||
|
"running", state.running.get()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BuildStatus webStatus() {
|
||||||
|
return status(WEB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BuildStatus emacsStatus() {
|
||||||
|
return status(EMACS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BuildStatus status(String id) {
|
||||||
|
CommandState state = state(id);
|
||||||
|
return new BuildStatus(state.running.get(), state.lastRun, state.lastExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object nullSafe(Object value) {
|
||||||
|
return value != null ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private CommandState state(String id) {
|
||||||
|
return states.computeIfAbsent(id, ignored -> new CommandState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class CommandState {
|
||||||
|
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
|
private volatile Instant lastRun;
|
||||||
|
private volatile Integer lastExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BuildStatus(boolean running, Instant lastRun, Integer lastExitCode) {}
|
||||||
|
}
|
||||||
98
src/main/java/org/zaine/app/service/CalendarService.java
Executable file
98
src/main/java/org/zaine/app/service/CalendarService.java
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
import org.zaine.app.dto.CalendarEventDTO;
|
||||||
|
import org.zaine.app.dto.CreateCalendarEventDTO;
|
||||||
|
import org.zaine.app.model.CalendarEvent;
|
||||||
|
import org.zaine.app.repositories.CalendarEventRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CalendarService {
|
||||||
|
|
||||||
|
private final CalendarEventRepository calendarEventRepository;
|
||||||
|
|
||||||
|
public CalendarService(CalendarEventRepository calendarEventRepository) {
|
||||||
|
this.calendarEventRepository = calendarEventRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CalendarEventDTO> getTodayEvents() {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
return getEventsForRange(today, today.plusDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CalendarEventDTO> getMonthEvents(int year, int month) {
|
||||||
|
LocalDate start = LocalDate.of(year, month, 1);
|
||||||
|
LocalDate end = start.with(TemporalAdjusters.firstDayOfNextMonth());
|
||||||
|
return getEventsForRange(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CalendarEventDTO> getEventsForRange(LocalDate from, LocalDate to) {
|
||||||
|
if (to.isBefore(from)) {
|
||||||
|
throw ApplicationException.badRequest("to must be on or after from");
|
||||||
|
}
|
||||||
|
|
||||||
|
ZoneId zone = ZoneId.systemDefault();
|
||||||
|
OffsetDateTime rangeStart = from.atStartOfDay(zone).toOffsetDateTime();
|
||||||
|
OffsetDateTime rangeEnd = to.atStartOfDay(zone).toOffsetDateTime();
|
||||||
|
|
||||||
|
return calendarEventRepository
|
||||||
|
.findByStartsAtLessThanAndEndsAtGreaterThanOrderByStartsAtAsc(rangeEnd, rangeStart)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalendarEventDTO getEvent(Long id) {
|
||||||
|
CalendarEvent event = calendarEventRepository.findById(id)
|
||||||
|
.orElseThrow(() -> ApplicationException.notFound("Calendar event not found"));
|
||||||
|
return toDto(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalendarEventDTO createEvent(CreateCalendarEventDTO dto) {
|
||||||
|
if (dto.title() == null || dto.title().isBlank()) {
|
||||||
|
throw ApplicationException.badRequest("title is required");
|
||||||
|
}
|
||||||
|
if (dto.startsAt() == null || dto.endsAt() == null) {
|
||||||
|
throw ApplicationException.badRequest("startsAt and endsAt are required");
|
||||||
|
}
|
||||||
|
if (!dto.endsAt().isAfter(dto.startsAt())) {
|
||||||
|
throw ApplicationException.badRequest("endsAt must be after startsAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
CalendarEvent event = new CalendarEvent();
|
||||||
|
event.setExternalId(blankToNull(dto.externalId()));
|
||||||
|
event.setTitle(dto.title().trim());
|
||||||
|
event.setDescription(blankToNull(dto.description()));
|
||||||
|
event.setLocation(blankToNull(dto.location()));
|
||||||
|
event.setStartsAt(dto.startsAt());
|
||||||
|
event.setEndsAt(dto.endsAt());
|
||||||
|
event.setAllDay(dto.allDay());
|
||||||
|
event.setSource(dto.source() == null || dto.source().isBlank() ? "manual" : dto.source().trim());
|
||||||
|
|
||||||
|
return toDto(calendarEventRepository.save(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CalendarEventDTO toDto(CalendarEvent event) {
|
||||||
|
return new CalendarEventDTO(
|
||||||
|
event.getId(),
|
||||||
|
event.getExternalId(),
|
||||||
|
event.getTitle(),
|
||||||
|
event.getDescription(),
|
||||||
|
event.getLocation(),
|
||||||
|
event.getStartsAt(),
|
||||||
|
event.getEndsAt(),
|
||||||
|
event.isAllDay(),
|
||||||
|
event.getSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/main/java/org/zaine/app/service/CalendarSyncService.java
Executable file
298
src/main/java/org/zaine/app/service/CalendarSyncService.java
Executable file
@@ -0,0 +1,298 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import net.fortuna.ical4j.data.CalendarBuilder;
|
||||||
|
import net.fortuna.ical4j.data.ParserException;
|
||||||
|
import net.fortuna.ical4j.model.Calendar;
|
||||||
|
import net.fortuna.ical4j.model.Component;
|
||||||
|
import net.fortuna.ical4j.model.Period;
|
||||||
|
import net.fortuna.ical4j.model.Property;
|
||||||
|
import net.fortuna.ical4j.model.component.CalendarComponent;
|
||||||
|
import net.fortuna.ical4j.model.component.VEvent;
|
||||||
|
import net.fortuna.ical4j.model.property.DateProperty;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.zaine.app.config.CalendarSyncProperties;
|
||||||
|
import org.zaine.app.dto.CalendarSyncResultDTO;
|
||||||
|
import org.zaine.app.model.CalendarEvent;
|
||||||
|
import org.zaine.app.repositories.CalendarEventRepository;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.temporal.Temporal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CalendarSyncService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CalendarSyncService.class);
|
||||||
|
|
||||||
|
private final CalendarEventRepository calendarEventRepository;
|
||||||
|
private final CalendarSyncProperties properties;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public CalendarSyncService(
|
||||||
|
CalendarEventRepository calendarEventRepository,
|
||||||
|
CalendarSyncProperties properties) {
|
||||||
|
this.calendarEventRepository = calendarEventRepository;
|
||||||
|
this.properties = properties;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(initialDelayString = "${calendar.sync.initial-delay-ms:15000}",
|
||||||
|
fixedDelayString = "${calendar.sync.fixed-delay-ms:1800000}")
|
||||||
|
public void scheduledSync() {
|
||||||
|
try {
|
||||||
|
CalendarSyncResultDTO result = sync();
|
||||||
|
log.info("Calendar sync complete: eventCount={}, sourceCount={}", result.eventCount(), result.sourceCount());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Scheduled calendar sync failed: {}", ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CalendarSyncResultDTO sync() {
|
||||||
|
List<CalendarSource> sources = configuredSources();
|
||||||
|
List<String> syncedSources = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
int eventCount = 0;
|
||||||
|
|
||||||
|
for (CalendarSource source : sources) {
|
||||||
|
try {
|
||||||
|
String ics = fetch(source);
|
||||||
|
List<CalendarEvent> events = parse(source.name(), ics);
|
||||||
|
calendarEventRepository.deleteBySource(source.name());
|
||||||
|
calendarEventRepository.saveAll(events);
|
||||||
|
syncedSources.add(source.name());
|
||||||
|
eventCount += events.size();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Failed to sync calendar source {}: {}", source.name(), ex.getMessage(), ex);
|
||||||
|
errors.add(source.name() + ": " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CalendarSyncResultDTO(OffsetDateTime.now(), syncedSources.size(), eventCount, syncedSources, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CalendarSource> configuredSources() {
|
||||||
|
List<CalendarSource> sources = new ArrayList<>();
|
||||||
|
|
||||||
|
if (properties.hasNextcloudCredentials()) {
|
||||||
|
for (String calendarId : properties.getNextcloudCalendarIds()) {
|
||||||
|
sources.add(new CalendarSource(
|
||||||
|
"nextcloud-" + calendarId,
|
||||||
|
nextcloudExportUrl(calendarId),
|
||||||
|
properties.getNextcloudUsername(),
|
||||||
|
properties.getNextcloudPassword()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!properties.getWorkIcsUrl().isBlank()) {
|
||||||
|
sources.add(new CalendarSource("work", properties.getWorkIcsUrl(), "", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextcloudExportUrl(String calendarId) {
|
||||||
|
String baseUrl = properties.getNextcloudBaseUrl().endsWith("/")
|
||||||
|
? properties.getNextcloudBaseUrl()
|
||||||
|
: properties.getNextcloudBaseUrl() + "/";
|
||||||
|
return baseUrl + calendarId + "?export";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetch(CalendarSource source) throws IOException, InterruptedException {
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(source.url()))
|
||||||
|
.GET()
|
||||||
|
.timeout(Duration.ofSeconds(60))
|
||||||
|
.header("Accept", "text/calendar,*/*");
|
||||||
|
|
||||||
|
if (!source.username().isBlank() || !source.password().isBlank()) {
|
||||||
|
String credentials = source.username() + ":" + source.password();
|
||||||
|
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
|
||||||
|
requestBuilder.header("Authorization", "Basic " + encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
throw new IOException("Calendar fetch returned HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CalendarEvent> parse(String source, String ics) throws IOException, ParserException {
|
||||||
|
CalendarBuilder builder = new CalendarBuilder();
|
||||||
|
Calendar calendar = builder.build(new ByteArrayInputStream(ics.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
ZoneId zoneId = properties.getZoneId();
|
||||||
|
ZonedDateTime from = LocalDate.now(zoneId).minusDays(properties.getDaysBack()).atStartOfDay(zoneId);
|
||||||
|
ZonedDateTime to = LocalDate.now(zoneId).plusDays(properties.getDaysForward()).plusDays(1).atStartOfDay(zoneId);
|
||||||
|
Period<ZonedDateTime> syncWindow = new Period<>(from, to);
|
||||||
|
|
||||||
|
List<CalendarEvent> events = new ArrayList<>();
|
||||||
|
for (CalendarComponent component : calendar.getComponents()) {
|
||||||
|
if (component instanceof VEvent event) {
|
||||||
|
events.addAll(toCalendarEvents(source, event, syncWindow, zoneId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduplicate(events.stream()
|
||||||
|
.filter(event -> event.getStartsAt().isBefore(to.toOffsetDateTime())
|
||||||
|
&& event.getEndsAt().isAfter(from.toOffsetDateTime()))
|
||||||
|
.sorted(Comparator.comparing(CalendarEvent::getStartsAt))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CalendarEvent> deduplicate(List<CalendarEvent> events) {
|
||||||
|
Map<String, CalendarEvent> uniqueEvents = new LinkedHashMap<>();
|
||||||
|
for (CalendarEvent event : events) {
|
||||||
|
CalendarEvent duplicate = uniqueEvents.putIfAbsent(event.getExternalId(), event);
|
||||||
|
if (duplicate != null) {
|
||||||
|
log.debug("Skipping duplicate calendar event from source {} at {}", event.getSource(), event.getStartsAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(uniqueEvents.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CalendarEvent> toCalendarEvents(
|
||||||
|
String source,
|
||||||
|
VEvent event,
|
||||||
|
Period<ZonedDateTime> syncWindow,
|
||||||
|
ZoneId zoneId) {
|
||||||
|
String uid = event.getUid().map(Property::getValue).orElseGet(() -> BigInteger.valueOf(event.hashCode()).abs().toString());
|
||||||
|
String title = propertyValue(event, Property.SUMMARY, "Untitled event");
|
||||||
|
String description = propertyValue(event, Property.DESCRIPTION, null);
|
||||||
|
String location = propertyValue(event, Property.LOCATION, null);
|
||||||
|
boolean allDay = isAllDay(event);
|
||||||
|
OffsetDateTime originalStart = event.getStartDate()
|
||||||
|
.map(DateProperty::getDate)
|
||||||
|
.map(value -> toOffsetDateTime(value, zoneId, false))
|
||||||
|
.orElse(null);
|
||||||
|
OffsetDateTime originalEnd = event.getEndDate()
|
||||||
|
.map(DateProperty::getDate)
|
||||||
|
.map(value -> toOffsetDateTime(value, zoneId, allDay))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (originalStart == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration duration = originalEnd == null || !originalEnd.isAfter(originalStart)
|
||||||
|
? Duration.ofHours(1)
|
||||||
|
: Duration.between(originalStart, originalEnd);
|
||||||
|
|
||||||
|
Set<Period<Temporal>> periods;
|
||||||
|
try {
|
||||||
|
periods = event.calculateRecurrenceSet(syncWindow);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Failed to expand recurrence for event {}; using original event only: {}", uid, ex.getMessage());
|
||||||
|
periods = Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periods.isEmpty()) {
|
||||||
|
CalendarEvent calendarEvent = buildEvent(
|
||||||
|
source,
|
||||||
|
uid,
|
||||||
|
originalStart,
|
||||||
|
originalStart.plus(duration),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
location,
|
||||||
|
allDay);
|
||||||
|
return List.of(calendarEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods.stream()
|
||||||
|
.map(period -> {
|
||||||
|
OffsetDateTime startsAt = toOffsetDateTime(period.getStart(), zoneId, false);
|
||||||
|
OffsetDateTime endsAt = toOffsetDateTime(period.getEnd(), zoneId, allDay);
|
||||||
|
if (!endsAt.isAfter(startsAt)) {
|
||||||
|
endsAt = startsAt.plus(duration);
|
||||||
|
}
|
||||||
|
return buildEvent(source, uid, startsAt, endsAt, title, description, location, allDay);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CalendarEvent buildEvent(
|
||||||
|
String source,
|
||||||
|
String uid,
|
||||||
|
OffsetDateTime startsAt,
|
||||||
|
OffsetDateTime endsAt,
|
||||||
|
String title,
|
||||||
|
String description,
|
||||||
|
String location,
|
||||||
|
boolean allDay) {
|
||||||
|
CalendarEvent event = new CalendarEvent();
|
||||||
|
event.setExternalId(source + ":" + uid + ":" + startsAt.toInstant());
|
||||||
|
event.setTitle(title);
|
||||||
|
event.setDescription(description);
|
||||||
|
event.setLocation(location);
|
||||||
|
event.setStartsAt(startsAt);
|
||||||
|
event.setEndsAt(endsAt);
|
||||||
|
event.setAllDay(allDay);
|
||||||
|
event.setSource(source);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String propertyValue(VEvent event, String propertyName, String fallback) {
|
||||||
|
return event.<Property>getProperty(propertyName)
|
||||||
|
.map(Property::getValue)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(value -> !value.isBlank())
|
||||||
|
.orElse(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllDay(VEvent event) {
|
||||||
|
return event.getStartDate()
|
||||||
|
.map(DateProperty::getDate)
|
||||||
|
.map(value -> value instanceof LocalDate)
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime toOffsetDateTime(Temporal temporal, ZoneId zoneId, boolean allDayEnd) {
|
||||||
|
if (temporal instanceof ZonedDateTime value) {
|
||||||
|
return value.toOffsetDateTime();
|
||||||
|
}
|
||||||
|
if (temporal instanceof OffsetDateTime value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (temporal instanceof LocalDateTime value) {
|
||||||
|
return value.atZone(zoneId).toOffsetDateTime();
|
||||||
|
}
|
||||||
|
if (temporal instanceof LocalDate value) {
|
||||||
|
LocalDate date = allDayEnd ? value : value;
|
||||||
|
return date.atStartOfDay(zoneId).toOffsetDateTime();
|
||||||
|
}
|
||||||
|
if (temporal instanceof Instant value) {
|
||||||
|
return value.atZone(zoneId).toOffsetDateTime();
|
||||||
|
}
|
||||||
|
return ZonedDateTime.from(temporal).withZoneSameInstant(zoneId).toOffsetDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CalendarSource(String name, String url, String username, String password) {
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/org/zaine/app/service/CommentsService.java
Executable file
65
src/main/java/org/zaine/app/service/CommentsService.java
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.model.Comments;
|
||||||
|
import org.zaine.app.repositories.CommentsRepository;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
import java.util.stream.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CommentsService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CommentsService.class);
|
||||||
|
|
||||||
|
private final CommentsRepository commentsRepository;
|
||||||
|
|
||||||
|
public CommentsService(CommentsRepository commentsRepository) {
|
||||||
|
this.commentsRepository = commentsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Comments> getAllComments() {
|
||||||
|
return commentsRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Comments> getAllCommentsBySlug(String page_slug) {
|
||||||
|
log.debug("Finding comments for page slug {}", page_slug);
|
||||||
|
List<Comments> allComments = commentsRepository.findAll();
|
||||||
|
List<Comments> filteredComments = allComments.stream()
|
||||||
|
.filter(comment -> page_slug.equals(comment.getPageSlug()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return filteredComments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Comments getCommentById(Integer id) {
|
||||||
|
if (id == null) {
|
||||||
|
log.warn("Comment lookup skipped because id was null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return commentsRepository.findById(id)
|
||||||
|
.orElseThrow(() -> ApplicationException.notFound("Comment not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Comments addComment(String pageSlug, String author, String content, Integer parentId) {
|
||||||
|
if (content == null || content.trim().isEmpty()) {
|
||||||
|
log.warn("Comment creation skipped because content was empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Comments comment = new Comments();
|
||||||
|
comment.setPageSlug(pageSlug);
|
||||||
|
comment.setAuthor(author);
|
||||||
|
comment.setContent(content);
|
||||||
|
comment.setParentId(parentId);
|
||||||
|
comment.setCreatedAt(Instant.now());
|
||||||
|
Comments saved = commentsRepository.save(comment);
|
||||||
|
log.info("Created comment id={} for pageSlug={}", saved.getId(), saved.getPageSlug());
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Comments> getCommentThread(Integer comment_id) {
|
||||||
|
log.debug("Finding comment thread for id {}", comment_id);
|
||||||
|
return commentsRepository.findCommentThread(comment_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main/java/org/zaine/app/service/CompetenciesService.java
Executable file
49
src/main/java/org/zaine/app/service/CompetenciesService.java
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.model.Competencies;
|
||||||
|
import org.zaine.app.repositories.CompetenciesRepository;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CompetenciesService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CompetenciesService.class);
|
||||||
|
private final CompetenciesRepository competenciesRepository;
|
||||||
|
private static final List<String> ALLOWED_STATES =
|
||||||
|
List.of("completed", "manager_review", "in_progress", "not_started", "comments");
|
||||||
|
|
||||||
|
public CompetenciesService(CompetenciesRepository competenciesRepository) {
|
||||||
|
this.competenciesRepository = competenciesRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Competencies> getAllCompetencies() {
|
||||||
|
return competenciesRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Competencies> getCompetenciesByGroup(String group) {
|
||||||
|
return competenciesRepository.findByGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Competencies getCompetencyById(Integer id) {
|
||||||
|
if (id == null) {
|
||||||
|
log.warn("Competency lookup skipped because id was null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return competenciesRepository.findById(id)
|
||||||
|
.orElseThrow(() -> ApplicationException.notFound("Competency not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateCompetencyState(Integer id, String newState) {
|
||||||
|
if (newState == null) {
|
||||||
|
throw new IllegalArgumentException("State must not be null");
|
||||||
|
}
|
||||||
|
if (!ALLOWED_STATES.contains(newState)) {
|
||||||
|
throw new IllegalArgumentException("Invalid state: " + newState);
|
||||||
|
}
|
||||||
|
Competencies competency = getCompetencyById(id);
|
||||||
|
competency.setState(newState);
|
||||||
|
competenciesRepository.save(competency);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main/java/org/zaine/app/service/MotalahService.java
Executable file
50
src/main/java/org/zaine/app/service/MotalahService.java
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.dto.MotalahSessionDTO;
|
||||||
|
import org.zaine.app.model.MotalahSession;
|
||||||
|
import org.zaine.app.repositories.MotalahSessionRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MotalahService {
|
||||||
|
|
||||||
|
private final MotalahSessionRepository repo;
|
||||||
|
|
||||||
|
public MotalahService(MotalahSessionRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All sessions, newest first. */
|
||||||
|
public List<MotalahSession> getAll() {
|
||||||
|
return repo.findAllByOrderByDateDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's sessions. */
|
||||||
|
public List<MotalahSession> getToday() {
|
||||||
|
return repo.findByDate(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sessions in a date range. */
|
||||||
|
public List<MotalahSession> getInRange(LocalDate from, LocalDate to) {
|
||||||
|
return repo.findInRange(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist a new session from the DTO. */
|
||||||
|
public MotalahSession create(MotalahSessionDTO dto) {
|
||||||
|
MotalahSession session = new MotalahSession();
|
||||||
|
session.setDate(dto.getDate() != null ? dto.getDate() : LocalDate.now());
|
||||||
|
session.setDurationMinutes(dto.getDurationMinutes());
|
||||||
|
session.setBookIds(dto.getBookIds() != null ? dto.getBookIds() : Collections.emptyList());
|
||||||
|
session.setNotes(dto.getNotes());
|
||||||
|
return repo.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a session by id. Silently no-ops if not found. */
|
||||||
|
public void delete(Long id) {
|
||||||
|
repo.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package org.zaine.app.service;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.zaine.app.model.Notes;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class NotesService {
|
|
||||||
|
|
||||||
private static List<Notes> notesList = new ArrayList<>();
|
|
||||||
|
|
||||||
public List<Notes> getAllNotes() {
|
|
||||||
return notesList;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
73
src/main/java/org/zaine/app/service/RpgSaveService.java
Executable file
73
src/main/java/org/zaine/app/service/RpgSaveService.java
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.dto.RpgSaveDTO;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RpgSaveService {
|
||||||
|
private static final String DEFAULT_SAVE_DIRECTORY =
|
||||||
|
"/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves";
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Path saveDirectory;
|
||||||
|
|
||||||
|
public RpgSaveService(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${play.rpg.save-dir:" + DEFAULT_SAVE_DIRECTORY + "}") String saveDirectory) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.saveDirectory = Path.of(saveDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgSaveDTO load(String slot) {
|
||||||
|
Path file = savePath(slot);
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(file.toFile(), RpgSaveDTO.class);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to load RPG save", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RpgSaveDTO save(String slot, JsonNode payload) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(saveDirectory);
|
||||||
|
RpgSaveDTO dto = new RpgSaveDTO();
|
||||||
|
dto.setSlot(slot);
|
||||||
|
dto.setPayload(payload);
|
||||||
|
|
||||||
|
ObjectNode wrapped = objectMapper.createObjectNode();
|
||||||
|
wrapped.put("slot", slot);
|
||||||
|
wrapped.set("payload", payload);
|
||||||
|
objectMapper.writerWithDefaultPrettyPrinter().writeValue(savePath(slot).toFile(), wrapped);
|
||||||
|
return dto;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to save RPG progress", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String slot) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(savePath(slot));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to delete RPG save", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path savePath(String slot) {
|
||||||
|
if (slot == null || !slot.matches("[A-Za-z0-9_-]{1,64}")) {
|
||||||
|
throw new IllegalArgumentException("Invalid RPG save slot");
|
||||||
|
}
|
||||||
|
return saveDirectory.resolve(slot + ".json").normalize();
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/main/java/org/zaine/app/service/TimesheetService.java
Executable file
262
src/main/java/org/zaine/app/service/TimesheetService.java
Executable file
@@ -0,0 +1,262 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.common.application.ApplicationException;
|
||||||
|
import org.zaine.app.model.TimesheetYear;
|
||||||
|
import org.zaine.app.repositories.TimesheetYearRepository;
|
||||||
|
import org.zaine.app.security.JwtUtil;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TimesheetService {
|
||||||
|
|
||||||
|
private static final Map<String, Double> CONTRACTED = Map.of(
|
||||||
|
"full", 7.5,
|
||||||
|
"half", 3.75,
|
||||||
|
"off", 0.0
|
||||||
|
);
|
||||||
|
|
||||||
|
private final TimesheetYearRepository repository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String jwtCookieName;
|
||||||
|
private final int maxPayloadBytes;
|
||||||
|
private final boolean authRequired;
|
||||||
|
|
||||||
|
public TimesheetService(
|
||||||
|
TimesheetYearRepository repository,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
JwtUtil jwtUtil,
|
||||||
|
@Value("${org.auth.api-key:}") String apiKey,
|
||||||
|
@Value("${auth.cookie.name:orgWebJwt}") String jwtCookieName,
|
||||||
|
@Value("${timesheet.max-payload-bytes:5242880}") int maxPayloadBytes,
|
||||||
|
@Value("${timesheet.auth.required:true}") boolean authRequired) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.jwtUtil = jwtUtil;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.jwtCookieName = jwtCookieName;
|
||||||
|
this.maxPayloadBytes = maxPayloadBytes;
|
||||||
|
this.authRequired = authRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireWriteAccess(String providedKey, String authorizationHeader, String cookieHeader) {
|
||||||
|
if (!authRequired || apiKey == null || apiKey.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (providedKey != null && apiKey.equals(providedKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String token = extractBearerToken(authorizationHeader, cookieHeader);
|
||||||
|
if (token != null && jwtUtil.isTokenValid(token)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw ApplicationException.unauthorized("Invalid or missing credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void enforcePayloadSize(JsonNode body) {
|
||||||
|
try {
|
||||||
|
int size = objectMapper.writeValueAsBytes(body).length;
|
||||||
|
if (size > maxPayloadBytes) {
|
||||||
|
throw ApplicationException.payloadTooLarge(
|
||||||
|
"Timesheet payload exceeds " + maxPayloadBytes + " bytes");
|
||||||
|
}
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw ApplicationException.badRequest("Invalid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractBearerToken(String authorizationHeader, String cookieHeader) {
|
||||||
|
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
||||||
|
return authorizationHeader.substring(7).trim();
|
||||||
|
}
|
||||||
|
if (cookieHeader == null || cookieHeader.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String prefix = jwtCookieName + "=";
|
||||||
|
for (String part : cookieHeader.split(";")) {
|
||||||
|
String trimmed = part.trim();
|
||||||
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
return trimmed.substring(prefix.length()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode getYear(int year) {
|
||||||
|
return repository.findById(year)
|
||||||
|
.map(row -> parsePayload(row.getPayload()))
|
||||||
|
.orElseGet(() -> emptyYear(year));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode emptyYear(int year) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
node.put("year", year);
|
||||||
|
node.set("entries", objectMapper.createObjectNode());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean yearExists(int year) {
|
||||||
|
return repository.existsById(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode putYear(int year, JsonNode body, String apiKeyHeader, String authorization, String cookie) {
|
||||||
|
requireWriteAccess(apiKeyHeader, authorization, cookie);
|
||||||
|
enforcePayloadSize(body);
|
||||||
|
ObjectNode normalized = body.isObject() ? (ObjectNode) body.deepCopy() : objectMapper.createObjectNode();
|
||||||
|
normalized.put("year", year);
|
||||||
|
if (!normalized.has("entries")) {
|
||||||
|
throw ApplicationException.badRequest("Payload must include entries object");
|
||||||
|
}
|
||||||
|
validateYearPayload(year, normalized);
|
||||||
|
saveRow(year, normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonNode mergeYear(int year, JsonNode body, String apiKeyHeader, String authorization, String cookie) {
|
||||||
|
requireWriteAccess(apiKeyHeader, authorization, cookie);
|
||||||
|
enforcePayloadSize(body);
|
||||||
|
JsonNode incoming = body.has("entries") ? body.get("entries") : body;
|
||||||
|
if (!incoming.isObject()) {
|
||||||
|
throw ApplicationException.badRequest("Expected entries object");
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode result;
|
||||||
|
Optional<TimesheetYear> existing = repository.findById(year);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
result = (ObjectNode) parsePayload(existing.get().getPayload()).deepCopy();
|
||||||
|
} else {
|
||||||
|
result = objectMapper.createObjectNode();
|
||||||
|
result.put("year", year);
|
||||||
|
result.set("entries", objectMapper.createObjectNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode entries = result.withObject("entries");
|
||||||
|
int replaced = 0;
|
||||||
|
int imported = 0;
|
||||||
|
var fields = incoming.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
var field = fields.next();
|
||||||
|
imported++;
|
||||||
|
String date = field.getKey();
|
||||||
|
if (entries.has(date)) {
|
||||||
|
replaced++;
|
||||||
|
}
|
||||||
|
ObjectNode target = entries.has(date) && entries.get(date).isObject()
|
||||||
|
? (ObjectNode) entries.get(date).deepCopy()
|
||||||
|
: objectMapper.createObjectNode();
|
||||||
|
target.put("date", date);
|
||||||
|
if (field.getValue().isObject()) {
|
||||||
|
field.getValue().fields().forEachRemaining(f -> target.set(f.getKey(), f.getValue()));
|
||||||
|
}
|
||||||
|
entries.set(date, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("year", year);
|
||||||
|
saveRow(year, result);
|
||||||
|
|
||||||
|
ObjectNode response = objectMapper.createObjectNode();
|
||||||
|
response.put("year", year);
|
||||||
|
response.set("entries", entries);
|
||||||
|
response.put("replaced", replaced);
|
||||||
|
response.put("imported", imported);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> weekSummary(int year) {
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("year", year);
|
||||||
|
Optional<TimesheetYear> row = repository.findById(year);
|
||||||
|
if (row.isEmpty()) {
|
||||||
|
summary.put("available", false);
|
||||||
|
summary.put("message", "No timesheet data");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = parsePayload(row.get().getPayload());
|
||||||
|
JsonNode entries = root.get("entries");
|
||||||
|
if (entries == null || !entries.isObject()) {
|
||||||
|
summary.put("available", false);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
if (today.getYear() != year) {
|
||||||
|
today = LocalDate.of(year, 12, 31);
|
||||||
|
}
|
||||||
|
LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||||
|
|
||||||
|
double hoursWorked = 0;
|
||||||
|
double contracted = 0;
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
LocalDate d = weekStart.plusDays(i);
|
||||||
|
String iso = d.toString();
|
||||||
|
JsonNode entry = entries.get(iso);
|
||||||
|
if (entry == null || !entry.isObject()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hoursWorked += entryHours(entry);
|
||||||
|
String schedule = entry.path("schedule").asText("off");
|
||||||
|
contracted += CONTRACTED.getOrDefault(schedule, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.put("available", true);
|
||||||
|
summary.put("weekStart", weekStart.toString());
|
||||||
|
summary.put("hoursWorked", round(hoursWorked));
|
||||||
|
summary.put("contracted", round(contracted));
|
||||||
|
summary.put("delta", round(hoursWorked - contracted));
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double entryHours(JsonNode entry) {
|
||||||
|
if (entry.has("hoursWorked") && !entry.get("hoursWorked").isNull()) {
|
||||||
|
return entry.get("hoursWorked").asDouble(0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double round(double v) {
|
||||||
|
return Math.round(v * 100.0) / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveRow(int year, JsonNode body) {
|
||||||
|
try {
|
||||||
|
TimesheetYear row = repository.findById(year).orElse(new TimesheetYear());
|
||||||
|
row.setYear(year);
|
||||||
|
row.setPayload(objectMapper.writeValueAsString(body));
|
||||||
|
row.setSavedAt(java.time.Instant.now());
|
||||||
|
repository.save(row);
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw ApplicationException.badRequest("Invalid JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateYearPayload(int year, JsonNode body) {
|
||||||
|
if (!body.has("entries") || !body.get("entries").isObject()) {
|
||||||
|
throw ApplicationException.badRequest("Payload must include entries object");
|
||||||
|
}
|
||||||
|
if (body.has("year") && body.get("year").asInt() != year) {
|
||||||
|
throw ApplicationException.badRequest("Year mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode parsePayload(String payload) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readTree(payload);
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw ApplicationException.failure("Corrupt timesheet payload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main/java/org/zaine/app/service/WirdService.java
Executable file
68
src/main/java/org/zaine/app/service/WirdService.java
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.zaine.app.dto.WirdEntryDTO;
|
||||||
|
import org.zaine.app.model.WirdEntry;
|
||||||
|
import org.zaine.app.repositories.WirdEntryRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WirdService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WirdService.class);
|
||||||
|
private final WirdEntryRepository repo;
|
||||||
|
|
||||||
|
public WirdService(WirdEntryRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getAllEntries() {
|
||||||
|
return repo.findAllByOrderByDateDescCreatedAtDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getTodayEntries() {
|
||||||
|
return repo.findByDateOrderByCreatedAtDesc(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getEntriesInRange(LocalDate from, LocalDate to) {
|
||||||
|
return repo.findByDateRange(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getEntriesByTypeInRange(String type, LocalDate from, LocalDate to) {
|
||||||
|
return repo.findByTypeAndDateRange(type, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WirdEntry createEntry(WirdEntryDTO dto) {
|
||||||
|
WirdEntry entry = new WirdEntry();
|
||||||
|
entry.setWirdType(dto.getWirdType());
|
||||||
|
entry.setDate(dto.getDate() != null ? dto.getDate() : LocalDate.now());
|
||||||
|
entry.setValue(dto.getValue());
|
||||||
|
entry.setNotes(dto.getNotes());
|
||||||
|
return repo.save(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteEntry(Long id) {
|
||||||
|
if (id == null) {
|
||||||
|
log.warn("Wird entry deletion skipped because id was null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
repo.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getNaflForDate(LocalDate date) {
|
||||||
|
Set<String> naflTypes = Set.of("salatul_tawbah", "salatul_hajaat", "tahajjud");
|
||||||
|
return repo.findAll()
|
||||||
|
.stream()
|
||||||
|
.filter(e -> naflTypes.contains(e.getWirdType()) && e.getDate().equals(date))
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WirdEntry> getKhatmEntries() {
|
||||||
|
return repo.findByWirdTypeOrderByDateDesc("khatm");
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/main/java/org/zaine/app/service/ZoneManifestService.java
Executable file
85
src/main/java/org/zaine/app/service/ZoneManifestService.java
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ZoneManifestService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Path manifestPath;
|
||||||
|
|
||||||
|
public ZoneManifestService(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${zone.manifest.path}") String manifestPath) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.manifestPath = Path.of(manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> loadEnrichedManifest() throws IOException {
|
||||||
|
if (!Files.isRegularFile(manifestPath)) {
|
||||||
|
throw new IOException("Manifest not found: " + manifestPath);
|
||||||
|
}
|
||||||
|
String raw = Files.readString(manifestPath);
|
||||||
|
Map<String, Object> manifest = objectMapper.readValue(raw, new TypeReference<>() {});
|
||||||
|
|
||||||
|
if (!manifest.containsKey("version") || ((Number) manifest.get("version")).intValue() < 2) {
|
||||||
|
manifest.put("version", 2);
|
||||||
|
}
|
||||||
|
manifest.putIfAbsent("integrations", defaultIntegrations());
|
||||||
|
manifest.putIfAbsent("widgets", defaultWidgets());
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> defaultIntegrations() {
|
||||||
|
Map<String, Object> integrations = new LinkedHashMap<>();
|
||||||
|
integrations.put("authoring", Map.of(
|
||||||
|
"pollMs", 5000,
|
||||||
|
"path", "/api/zone/status"
|
||||||
|
));
|
||||||
|
integrations.put("watcher", Map.of(
|
||||||
|
"pollMs", 10000,
|
||||||
|
"path", "/api/zone/status"
|
||||||
|
));
|
||||||
|
integrations.put("timesheet", Map.of(
|
||||||
|
"pollMs", 60000,
|
||||||
|
"path", "/api/zone/status"
|
||||||
|
));
|
||||||
|
return integrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<Map<String, Object>> defaultWidgets() {
|
||||||
|
return List.of(
|
||||||
|
Map.of(
|
||||||
|
"id", "authoring-queue",
|
||||||
|
"type", "queue",
|
||||||
|
"title", "Authoring builds",
|
||||||
|
"integration", "authoring"
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"id", "watcher",
|
||||||
|
"type", "badge",
|
||||||
|
"title", "Lima watcher",
|
||||||
|
"integration", "watcher"
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"id", "timesheet-summary",
|
||||||
|
"type", "timesheet",
|
||||||
|
"title", "Hours this week",
|
||||||
|
"integration", "timesheet"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
607
src/main/java/org/zaine/app/service/ZoneStatusService.java
Executable file
607
src/main/java/org/zaine/app/service/ZoneStatusService.java
Executable file
@@ -0,0 +1,607 @@
|
|||||||
|
package org.zaine.app.service;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import java.nio.file.FileStore;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Service
|
||||||
|
|
||||||
|
public class ZoneStatusService {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ZoneStatusService.class);
|
||||||
|
|
||||||
|
private static final long SCRIPT_STALE_HOURS = 48;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
private final String authoringUrl;
|
||||||
|
|
||||||
|
private final String watcherUnit;
|
||||||
|
|
||||||
|
private final Path buildLogPath;
|
||||||
|
|
||||||
|
private final Path scriptsLogDir;
|
||||||
|
|
||||||
|
private final Path storagePath;
|
||||||
|
|
||||||
|
private final TimesheetService timesheetService;
|
||||||
|
|
||||||
|
private final BuildRunStateService buildRunState;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public ZoneStatusService(
|
||||||
|
|
||||||
|
RestTemplate restTemplate,
|
||||||
|
|
||||||
|
@Value("${zone.authoring.url}") String authoringUrl,
|
||||||
|
|
||||||
|
@Value("${zone.watcher.unit}") String watcherUnit,
|
||||||
|
|
||||||
|
@Value("${zone.build.log}") String buildLogPath,
|
||||||
|
|
||||||
|
@Value("${zone.scripts.log.dir}") String scriptsLogDir,
|
||||||
|
|
||||||
|
@Value("${zone.resources.storage.path:/}") String storagePath,
|
||||||
|
|
||||||
|
TimesheetService timesheetService,
|
||||||
|
|
||||||
|
BuildRunStateService buildRunState) {
|
||||||
|
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
|
||||||
|
this.authoringUrl = authoringUrl;
|
||||||
|
|
||||||
|
this.watcherUnit = watcherUnit;
|
||||||
|
|
||||||
|
this.buildLogPath = Path.of(buildLogPath);
|
||||||
|
|
||||||
|
this.scriptsLogDir = Path.of(scriptsLogDir);
|
||||||
|
|
||||||
|
this.storagePath = Path.of(storagePath);
|
||||||
|
|
||||||
|
this.timesheetService = timesheetService;
|
||||||
|
|
||||||
|
this.buildRunState = buildRunState;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public Map<String, Object> collectStatus() {
|
||||||
|
|
||||||
|
Map<String, Object> status = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
status.put("authoring", fetchAuthoringDetail());
|
||||||
|
|
||||||
|
status.put("watcher", fetchWatcher());
|
||||||
|
|
||||||
|
status.put("orgWebLog", tailLog(buildLogPath, 30));
|
||||||
|
|
||||||
|
status.put("scripts", fetchScriptLogs());
|
||||||
|
|
||||||
|
status.put("lastRuns", buildRunState.lastRunsSnapshot());
|
||||||
|
|
||||||
|
status.put("systemResources", fetchSystemResources());
|
||||||
|
|
||||||
|
status.put("timesheet", fetchTimesheetSummary());
|
||||||
|
|
||||||
|
return status;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fetchSystemResources() {
|
||||||
|
Map<String, Object> resources = new LinkedHashMap<>();
|
||||||
|
resources.put("available", true);
|
||||||
|
resources.put("cpu", fetchCpu());
|
||||||
|
resources.put("memory", fetchMemory());
|
||||||
|
resources.put("storage", fetchStorage());
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fetchCpu() {
|
||||||
|
Map<String, Object> cpu = new LinkedHashMap<>();
|
||||||
|
java.lang.management.OperatingSystemMXBean os =
|
||||||
|
java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||||
|
cpu.put("cores", os.getAvailableProcessors());
|
||||||
|
cpu.put("loadAverage", os.getSystemLoadAverage());
|
||||||
|
if (os instanceof com.sun.management.OperatingSystemMXBean sunOs) {
|
||||||
|
double load = sunOs.getCpuLoad();
|
||||||
|
if (load >= 0) {
|
||||||
|
cpu.put("usagePct", Math.round(load * 1000.0) / 10.0);
|
||||||
|
}
|
||||||
|
double processLoad = sunOs.getProcessCpuLoad();
|
||||||
|
if (processLoad >= 0) {
|
||||||
|
cpu.put("processUsagePct", Math.round(processLoad * 1000.0) / 10.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cpu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fetchMemory() {
|
||||||
|
Map<String, Object> memory = new LinkedHashMap<>();
|
||||||
|
java.lang.management.OperatingSystemMXBean os =
|
||||||
|
java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||||
|
if (os instanceof com.sun.management.OperatingSystemMXBean sunOs) {
|
||||||
|
long total = sunOs.getTotalPhysicalMemorySize();
|
||||||
|
long free = sunOs.getFreePhysicalMemorySize();
|
||||||
|
long used = Math.max(0, total - free);
|
||||||
|
memory.put("totalBytes", total);
|
||||||
|
memory.put("freeBytes", free);
|
||||||
|
memory.put("usedBytes", used);
|
||||||
|
memory.put("usedPct", pct(used, total));
|
||||||
|
} else {
|
||||||
|
memory.put("available", false);
|
||||||
|
memory.put("message", "Physical memory stats unavailable");
|
||||||
|
}
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fetchStorage() {
|
||||||
|
Map<String, Object> storage = new LinkedHashMap<>();
|
||||||
|
storage.put("path", storagePath.toString());
|
||||||
|
try {
|
||||||
|
FileStore store = Files.getFileStore(storagePath);
|
||||||
|
long total = store.getTotalSpace();
|
||||||
|
long usable = store.getUsableSpace();
|
||||||
|
long used = Math.max(0, total - usable);
|
||||||
|
storage.put("totalBytes", total);
|
||||||
|
storage.put("usableBytes", usable);
|
||||||
|
storage.put("usedBytes", used);
|
||||||
|
storage.put("usedPct", pct(used, total));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
storage.put("available", false);
|
||||||
|
storage.put("message", ex.getMessage());
|
||||||
|
}
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double pct(long value, long total) {
|
||||||
|
if (total <= 0) return 0.0;
|
||||||
|
return Math.round((value * 1000.0) / total) / 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
|
||||||
|
private Map<String, Object> fetchAuthoringDetail() {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.getForEntity(
|
||||||
|
|
||||||
|
authoringUrl + "/api/build", Map.class);
|
||||||
|
|
||||||
|
if (response.getBody() != null) {
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>(response.getBody());
|
||||||
|
|
||||||
|
enrichAuthoringDetail(body);
|
||||||
|
|
||||||
|
return body;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (RestClientException ex) {
|
||||||
|
|
||||||
|
log.debug("Authoring status unavailable: {}", ex.getMessage());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
|
||||||
|
"running", false,
|
||||||
|
|
||||||
|
"queued", 0,
|
||||||
|
|
||||||
|
"message", "Authoring service unreachable",
|
||||||
|
|
||||||
|
"error", true
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
|
||||||
|
private void enrichAuthoringDetail(Map<String, Object> authoring) {
|
||||||
|
|
||||||
|
Object pendingObj = authoring.get("pending");
|
||||||
|
|
||||||
|
int pendingCount = 0;
|
||||||
|
|
||||||
|
if (pendingObj instanceof List<?> pending) {
|
||||||
|
|
||||||
|
pendingCount = pending.size();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
authoring.put("pendingCount", pendingCount);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Object failedObj = authoring.get("failed");
|
||||||
|
|
||||||
|
String lastFailedTitle = null;
|
||||||
|
|
||||||
|
if (failedObj instanceof List<?> failed && !failed.isEmpty()) {
|
||||||
|
|
||||||
|
Object last = failed.get(failed.size() - 1);
|
||||||
|
|
||||||
|
if (last instanceof Map<?, ?> job) {
|
||||||
|
|
||||||
|
Object title = job.get("title");
|
||||||
|
|
||||||
|
if (title != null) {
|
||||||
|
|
||||||
|
lastFailedTitle = title.toString();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastFailedTitle != null) {
|
||||||
|
|
||||||
|
authoring.put("lastFailedTitle", lastFailedTitle);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> fetchTimesheetSummary() {
|
||||||
|
try {
|
||||||
|
return timesheetService.weekSummary(java.time.Year.now().getValue());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.debug("Timesheet summary unavailable: {}", ex.getMessage());
|
||||||
|
Map<String, Object> fallback = new LinkedHashMap<>();
|
||||||
|
fallback.put("available", false);
|
||||||
|
fallback.put("message", "Timesheet unavailable");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> fetchWatcher() {
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
result.put("unit", watcherUnit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
Process process = new ProcessBuilder(
|
||||||
|
|
||||||
|
"systemctl", "show", watcherUnit,
|
||||||
|
|
||||||
|
"--property=ActiveState,SubState,MainPID"
|
||||||
|
|
||||||
|
).redirectErrorStream(true).start();
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
|
||||||
|
process.destroyForcibly();
|
||||||
|
|
||||||
|
result.put("active", "unknown");
|
||||||
|
|
||||||
|
result.put("message", "systemctl timed out");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
for (String line : output.split("\n")) {
|
||||||
|
|
||||||
|
int eq = line.indexOf('=');
|
||||||
|
|
||||||
|
if (eq > 0) {
|
||||||
|
|
||||||
|
result.put(line.substring(0, eq).trim(), line.substring(eq + 1).trim());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception ex) {
|
||||||
|
|
||||||
|
log.debug("Watcher status failed: {}", ex.getMessage());
|
||||||
|
|
||||||
|
result.put("active", "unknown");
|
||||||
|
|
||||||
|
result.put("message", ex.getMessage());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> tailLog(Path logFile, int maxLines) {
|
||||||
|
|
||||||
|
Map<String, Object> meta = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
meta.put("path", logFile.toString());
|
||||||
|
|
||||||
|
if (!Files.isRegularFile(logFile)) {
|
||||||
|
|
||||||
|
meta.put("tail", List.of());
|
||||||
|
|
||||||
|
meta.put("message", "Log file not found");
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
meta.put("mtime", Files.getLastModifiedTime(logFile).toInstant().toString());
|
||||||
|
|
||||||
|
meta.put("size", Files.size(logFile));
|
||||||
|
|
||||||
|
meta.put("tail", readLastLines(logFile, maxLines));
|
||||||
|
|
||||||
|
} catch (IOException ex) {
|
||||||
|
|
||||||
|
meta.put("tail", List.of());
|
||||||
|
|
||||||
|
meta.put("message", ex.getMessage());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private List<String> readLastLines(Path file, int maxLines) throws IOException {
|
||||||
|
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
|
||||||
|
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||||
|
|
||||||
|
long pointer = raf.length() - 1;
|
||||||
|
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
|
|
||||||
|
while (pointer >= 0 && lines.size() < maxLines) {
|
||||||
|
|
||||||
|
raf.seek(pointer);
|
||||||
|
|
||||||
|
int ch = raf.read();
|
||||||
|
|
||||||
|
if (ch == '\n') {
|
||||||
|
|
||||||
|
if (line.length() > 0) {
|
||||||
|
|
||||||
|
lines.add(0, line.reverse().toString());
|
||||||
|
|
||||||
|
line.setLength(0);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (ch != '\r') {
|
||||||
|
|
||||||
|
line.append((char) ch);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer--;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length() > 0 && lines.size() < maxLines) {
|
||||||
|
|
||||||
|
lines.add(0, line.reverse().toString());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> fetchScriptLogs() {
|
||||||
|
|
||||||
|
Map<String, Object> scripts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
scripts.put("orgWebBuild", scriptHealth(
|
||||||
|
|
||||||
|
"Website build log",
|
||||||
|
|
||||||
|
scriptsLogDir.resolve("org-web.log")
|
||||||
|
|
||||||
|
));
|
||||||
|
|
||||||
|
scripts.put("calibreSync", calibreSyncHealth(scriptsLogDir.resolve("org-books-calibre.log")));
|
||||||
|
|
||||||
|
return scripts;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> scriptHealth(String label, Path path) {
|
||||||
|
|
||||||
|
Map<String, Object> info = statLog(path);
|
||||||
|
|
||||||
|
info.put("label", label);
|
||||||
|
|
||||||
|
applyStale(info);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> calibreSyncHealth(Path path) {
|
||||||
|
|
||||||
|
Map<String, Object> info = statLog(path);
|
||||||
|
|
||||||
|
info.put("label", "Calibre export");
|
||||||
|
|
||||||
|
applyStale(info);
|
||||||
|
|
||||||
|
if (Files.isRegularFile(path)) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
List<String> tail = readLastLines(path, 1);
|
||||||
|
|
||||||
|
if (!tail.isEmpty()) {
|
||||||
|
|
||||||
|
info.put("lastLine", tail.get(tail.size() - 1));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IOException ex) {
|
||||||
|
|
||||||
|
info.put("readError", ex.getMessage());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private Map<String, Object> statLog(Path path) {
|
||||||
|
|
||||||
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
info.put("path", path.toString());
|
||||||
|
|
||||||
|
if (Files.isRegularFile(path)) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
Instant mtime = Files.getLastModifiedTime(path).toInstant();
|
||||||
|
|
||||||
|
info.put("mtime", mtime.toString());
|
||||||
|
|
||||||
|
info.put("size", Files.size(path));
|
||||||
|
|
||||||
|
info.put("exists", true);
|
||||||
|
|
||||||
|
} catch (IOException ex) {
|
||||||
|
|
||||||
|
info.put("error", ex.getMessage());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
info.put("exists", false);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
private void applyStale(Map<String, Object> info) {
|
||||||
|
|
||||||
|
Object mtimeObj = info.get("mtime");
|
||||||
|
|
||||||
|
if (mtimeObj == null) {
|
||||||
|
|
||||||
|
info.put("stale", true);
|
||||||
|
|
||||||
|
info.put("staleReason", "missing");
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
Instant mtime = Instant.parse(mtimeObj.toString());
|
||||||
|
|
||||||
|
boolean stale = Duration.between(mtime, Instant.now()).toHours() > SCRIPT_STALE_HOURS;
|
||||||
|
|
||||||
|
info.put("stale", stale);
|
||||||
|
|
||||||
|
if (stale) {
|
||||||
|
|
||||||
|
info.put("staleReason", "older than " + SCRIPT_STALE_HOURS + "h");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception ex) {
|
||||||
|
|
||||||
|
info.put("stale", true);
|
||||||
|
|
||||||
|
info.put("staleReason", "unparseable mtime");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
30
src/main/java/org/zaine/app/user/User.java
Executable file
30
src/main/java/org/zaine/app/user/User.java
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
package org.zaine.app.user;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password; // stored as BCrypt hash
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String role; // e.g. "ROLE_USER", "ROLE_ADMIN"
|
||||||
|
|
||||||
|
// Getters & setters
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public void setRole(String role) { this.role = role; }
|
||||||
|
}
|
||||||
30
src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
Executable file
30
src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/main/java/org/zaine/app/user/UserDetailsServiceImpl.java
|
||||||
|
package org.zaine.app.user;
|
||||||
|
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.*;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserDetailsServiceImpl(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
|
||||||
|
return new org.springframework.security.core.userdetails.User(
|
||||||
|
user.getUsername(),
|
||||||
|
user.getPassword(),
|
||||||
|
List.of(new SimpleGrantedAuthority(user.getRole()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/main/java/org/zaine/app/user/UserRepository.java
Executable file
9
src/main/java/org/zaine/app/user/UserRepository.java
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
package org.zaine.app.user;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
}
|
||||||
44
src/main/resources/application-dev.properties
Executable file
44
src/main/resources/application-dev.properties
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver}
|
||||||
|
calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db
|
||||||
|
|
||||||
|
server.port=9015
|
||||||
|
spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false}
|
||||||
|
spring.jpa.hibernate.ddl-auto=none
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
spring.flyway.baseline-version=1
|
||||||
|
timesheet.max-payload-bytes=5242880
|
||||||
|
|
||||||
|
jwt.secret=${JWT_SECRET}
|
||||||
|
jwt.expiration-ms=${JWT_EXPIRATION_MS:86400000}
|
||||||
|
auth.cookie.name=orgWebJwt
|
||||||
|
auth.cookie.secure=false
|
||||||
|
auth.cookie.max-age-seconds=86400
|
||||||
|
org.auth.api-key=${ORG_BACKEND_API_KEY:}
|
||||||
|
timesheet.auth.required=${TIMESHEET_AUTH_REQUIRED:false}
|
||||||
|
|
||||||
|
zone.build.dir=/home/zaine/master-folder/org-platform/org_web
|
||||||
|
zone.build.log=/home/zaine/master-folder/org-platform/org_web/org-web-build.log
|
||||||
|
zone.manifest.path=/home/zaine/master-folder/org-platform/zone/data/manifest.json
|
||||||
|
zone.authoring.url=http://127.0.0.1:8765
|
||||||
|
zone.watcher.unit=watcher.service
|
||||||
|
zone.scripts.log.dir=/home/zaine/logs
|
||||||
|
zone.resources.storage.path=/
|
||||||
|
emacs.run.dir=/home/zaine
|
||||||
|
emacs.run.log=/home/zaine/logs/emacs.log
|
||||||
|
combined.run.log=/home/zaine/logs/combined.log
|
||||||
|
adventure.resources.free.dir=/home/zaine
|
||||||
|
adventure.resources.free.log=/home/zaine/logs/adventure-resources.log
|
||||||
|
adventure.resources.free.command=sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af
|
||||||
|
guacamole.run.dir=/home/zaine
|
||||||
|
guacamole.container.name=guacamole
|
||||||
|
guacamole.start.log=/home/zaine/logs/guacamole-start.log
|
||||||
|
guacamole.stop.log=/home/zaine/logs/guacamole-stop.log
|
||||||
|
nostalgia.run.log=/home/zaine/logs/nostalgia-prod.log
|
||||||
|
play.rpg.save-dir=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves
|
||||||
|
|
||||||
|
spring.web.resources.add-mappings=false
|
||||||
44
src/main/resources/application-prod.properties
Executable file
44
src/main/resources/application-prod.properties
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
spring.datasource.url=${SPRING_DATASOURCE_URL}
|
||||||
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
|
||||||
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver}
|
||||||
|
calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db
|
||||||
|
|
||||||
|
server.port=9010
|
||||||
|
spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false}
|
||||||
|
spring.jpa.hibernate.ddl-auto=none
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
# Phase 2 DB had tables before Flyway; baseline records V1 as applied without re-running DDL
|
||||||
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
spring.flyway.baseline-version=1
|
||||||
|
timesheet.max-payload-bytes=5242880
|
||||||
|
|
||||||
|
jwt.secret=${JWT_SECRET}
|
||||||
|
jwt.expiration-ms=${JWT_EXPIRATION_MS:86400000}
|
||||||
|
auth.cookie.name=orgWebJwt
|
||||||
|
auth.cookie.domain=.zainezq.com
|
||||||
|
auth.cookie.secure=true
|
||||||
|
auth.cookie.max-age-seconds=86400
|
||||||
|
org.auth.api-key=${ORG_BACKEND_API_KEY:}
|
||||||
|
timesheet.auth.required=${TIMESHEET_AUTH_REQUIRED:false}
|
||||||
|
|
||||||
|
zone.build.dir=/home/zaine/master-folder/org-platform/org_web
|
||||||
|
zone.build.log=/home/zaine/master-folder/org-platform/org_web/org-web-build.log
|
||||||
|
zone.manifest.path=/home/zaine/master-folder/org-platform/zone/data/manifest.json
|
||||||
|
zone.authoring.url=http://127.0.0.1:8765
|
||||||
|
zone.watcher.unit=watcher.service
|
||||||
|
zone.scripts.log.dir=/home/zaine/logs
|
||||||
|
zone.resources.storage.path=/
|
||||||
|
emacs.run.dir=/home/zaine
|
||||||
|
emacs.run.log=/home/zaine/logs/emacs.log
|
||||||
|
combined.run.log=/home/zaine/logs/combined.log
|
||||||
|
adventure.resources.free.dir=/home/zaine
|
||||||
|
adventure.resources.free.log=/home/zaine/logs/adventure-resources.log
|
||||||
|
adventure.resources.free.command=sudo -n sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches' && /usr/bin/docker system prune -af && /usr/bin/docker builder prune -af
|
||||||
|
guacamole.run.dir=/home/zaine
|
||||||
|
guacamole.container.name=guacamole
|
||||||
|
guacamole.start.log=/home/zaine/logs/guacamole-start.log
|
||||||
|
guacamole.stop.log=/home/zaine/logs/guacamole-stop.log
|
||||||
|
nostalgia.run.log=/home/zaine/logs/nostalgia-prod.log
|
||||||
|
play.rpg.save-dir=/home/zaine/master-folder/org-platform/org_backend/data/rpg-saves
|
||||||
5
src/main/resources/db/migration/V1__timesheet_year.sql
Executable file
5
src/main/resources/db/migration/V1__timesheet_year.sql
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS timesheet_year (
|
||||||
|
year INT PRIMARY KEY,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
6
src/main/resources/db/migration/V2__ensure_timesheet_year.sql
Executable file
6
src/main/resources/db/migration/V2__ensure_timesheet_year.sql
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Ensures timesheet table exists when V1 was skipped by Flyway baseline (non-empty schema, no table yet).
|
||||||
|
CREATE TABLE IF NOT EXISTS timesheet_year (
|
||||||
|
year INT PRIMARY KEY,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
saved_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
38
src/main/resources/db/migration/V3__calendar_and_daily_checkins.sql
Executable file
38
src/main/resources/db/migration/V3__calendar_and_daily_checkins.sql
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
external_id VARCHAR(255) UNIQUE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
location VARCHAR(255),
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ends_at TIMESTAMPTZ NOT NULL,
|
||||||
|
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
source VARCHAR(64) NOT NULL DEFAULT 'manual',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_range
|
||||||
|
ON calendar_events (starts_at, ends_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_checkins (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
sleep_hours NUMERIC(4, 2),
|
||||||
|
morning_energy_level INTEGER,
|
||||||
|
morning_mood_level INTEGER,
|
||||||
|
morning_note VARCHAR(255),
|
||||||
|
morning_completed_at TIMESTAMPTZ,
|
||||||
|
evening_mood_level INTEGER,
|
||||||
|
stress_level INTEGER,
|
||||||
|
reflection TEXT,
|
||||||
|
best_thing_today VARCHAR(255),
|
||||||
|
evening_completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_daily_checkins_sleep_hours CHECK (sleep_hours IS NULL OR (sleep_hours >= 0 AND sleep_hours <= 24)),
|
||||||
|
CONSTRAINT chk_daily_checkins_morning_energy CHECK (morning_energy_level IS NULL OR (morning_energy_level BETWEEN 1 AND 10)),
|
||||||
|
CONSTRAINT chk_daily_checkins_morning_mood CHECK (morning_mood_level IS NULL OR (morning_mood_level BETWEEN 1 AND 10)),
|
||||||
|
CONSTRAINT chk_daily_checkins_evening_mood CHECK (evening_mood_level IS NULL OR (evening_mood_level BETWEEN 1 AND 10)),
|
||||||
|
CONSTRAINT chk_daily_checkins_stress CHECK (stress_level IS NULL OR (stress_level BETWEEN 1 AND 10))
|
||||||
|
);
|
||||||
7
src/main/resources/db/migration/V4__daily_checkin_practice_fields.sql
Executable file
7
src/main/resources/db/migration/V4__daily_checkin_practice_fields.sql
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE daily_checkins
|
||||||
|
ADD COLUMN IF NOT EXISTS fajr BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS quran BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS exercise BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS duties BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS zikr BOOLEAN,
|
||||||
|
ADD COLUMN IF NOT EXISTS salah BOOLEAN;
|
||||||
69
src/main/resources/db/migration/V5__period_companion.sql
Executable file
69
src/main/resources/db/migration/V5__period_companion.sql
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pc_cycles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE,
|
||||||
|
cycle_length INTEGER,
|
||||||
|
period_length INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_pc_cycles_dates CHECK (end_date IS NULL OR end_date >= start_date),
|
||||||
|
CONSTRAINT chk_pc_cycles_cycle_length CHECK (cycle_length IS NULL OR cycle_length BETWEEN 15 AND 60),
|
||||||
|
CONSTRAINT chk_pc_cycles_period_length CHECK (period_length IS NULL OR period_length BETWEEN 1 AND 15)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_pc_cycles_start_date ON pc_cycles (start_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_cycles_start_date ON pc_cycles (start_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_daily_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
flow_level VARCHAR(32),
|
||||||
|
mood VARCHAR(64),
|
||||||
|
energy_level VARCHAR(32),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_daily_logs_date ON pc_daily_logs (date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_symptoms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(80) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_daily_log_symptoms (
|
||||||
|
daily_log_id BIGINT NOT NULL REFERENCES pc_daily_logs(id) ON DELETE CASCADE,
|
||||||
|
symptom_id BIGINT NOT NULL REFERENCES pc_symptoms(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (daily_log_id, symptom_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_prediction_snapshots (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
predicted_period_date DATE NOT NULL,
|
||||||
|
predicted_ovulation_date DATE NOT NULL,
|
||||||
|
fertile_window_start DATE NOT NULL,
|
||||||
|
fertile_window_end DATE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_prediction_snapshots_generated_at
|
||||||
|
ON pc_prediction_snapshots (generated_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_support_messages (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
author_name VARCHAR(80),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO pc_symptoms (name)
|
||||||
|
VALUES
|
||||||
|
('Cramps'),
|
||||||
|
('Bloating'),
|
||||||
|
('Fatigue'),
|
||||||
|
('Headache'),
|
||||||
|
('Back pain'),
|
||||||
|
('Breast tenderness'),
|
||||||
|
('Nausea')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
13
src/main/resources/db/migration/V6__period_companion_settings.sql
Executable file
13
src/main/resources/db/migration/V6__period_companion_settings.sql
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pc_settings (
|
||||||
|
id SMALLINT PRIMARY KEY DEFAULT 1,
|
||||||
|
average_cycle_length INTEGER NOT NULL DEFAULT 28,
|
||||||
|
average_period_length INTEGER NOT NULL DEFAULT 5,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_pc_settings_singleton CHECK (id = 1),
|
||||||
|
CONSTRAINT chk_pc_settings_cycle_length CHECK (average_cycle_length BETWEEN 15 AND 60),
|
||||||
|
CONSTRAINT chk_pc_settings_period_length CHECK (average_period_length BETWEEN 1 AND 15)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO pc_settings (id, average_cycle_length, average_period_length)
|
||||||
|
VALUES (1, 28, 5)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
42
src/main/resources/db/migration/V7__period_companion_wellbeing.sql
Executable file
42
src/main/resources/db/migration/V7__period_companion_wellbeing.sql
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS pc_fasting_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
fast_date DATE NOT NULL UNIQUE,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT chk_pc_fasting_logs_status CHECK (status IN ('MISSED', 'MADE_UP'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_fasting_logs_fast_date
|
||||||
|
ON pc_fasting_logs (fast_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pc_daily_reflections (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
date DATE NOT NULL UNIQUE,
|
||||||
|
prompt VARCHAR(255) NOT NULL,
|
||||||
|
response TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_daily_reflections_date
|
||||||
|
ON pc_daily_reflections (date DESC);
|
||||||
|
|
||||||
|
INSERT INTO pc_symptoms (name)
|
||||||
|
VALUES
|
||||||
|
('Acne'),
|
||||||
|
('Appetite changes'),
|
||||||
|
('Constipation'),
|
||||||
|
('Diarrhea'),
|
||||||
|
('Dizziness'),
|
||||||
|
('Insomnia'),
|
||||||
|
('Joint pain'),
|
||||||
|
('Mood swings'),
|
||||||
|
('Pelvic pain'),
|
||||||
|
('Sleepiness'),
|
||||||
|
('Spotting'),
|
||||||
|
('Tenderness'),
|
||||||
|
('Digestive discomfort'),
|
||||||
|
('Sugar cravings')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.zaine.app;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
public class ApplicationTest {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user