Compare commits
53 Commits
0ff94c308f
...
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 |
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/
|
||||
application.properties
|
||||
application-*.properties
|
||||
!src/main/resources/application-dev.properties
|
||||
!src/main/resources/application-prod.properties
|
||||
.settings/
|
||||
.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:
|
||||
|
||||
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
|
||||
48
pom.xml
Normal file → Executable file
48
pom.xml
Normal file → Executable file
@@ -42,7 +42,17 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.8</version>
|
||||
</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 -->
|
||||
@@ -51,10 +61,46 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
<finalName>org-backend</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
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.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class Application {
|
||||
public static void main(String[] 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";
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.zaine.app.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
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.model.Notes;
|
||||
import org.zaine.app.repositories.NotesRepository;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
public class NotesController {
|
||||
@Autowired
|
||||
private NotesRepository notesRepository;
|
||||
|
||||
@GetMapping("/notes")
|
||||
public List<Notes> getAllNotes() {
|
||||
return notesRepository.findAll();
|
||||
}
|
||||
|
||||
@PostMapping("/notes")
|
||||
public Notes createNote(@RequestBody Notes note) {
|
||||
return notesRepository.save(note);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
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,59 +0,0 @@
|
||||
package org.zaine.app.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import org.hibernate.annotations.UuidGenerator;
|
||||
|
||||
import java.sql.Date;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "public_notes")
|
||||
public class Notes {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private UUID id;
|
||||
|
||||
@Column(name="content")
|
||||
private String content;
|
||||
|
||||
@Column(name="author_name")
|
||||
private String authorName;
|
||||
|
||||
@Column(name="created_at")
|
||||
private Date createdAt;
|
||||
|
||||
|
||||
public Notes() {
|
||||
this.createdAt = new Date(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.zaine.app.repositories;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.UUID;
|
||||
import org.zaine.app.model.Notes;
|
||||
|
||||
public interface NotesRepository extends JpaRepository<Notes, UUID> {
|
||||
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user