Compare commits

...

44 Commits

Author SHA1 Message Date
767b22f945 updates
Some checks failed
Build Org Backend / build (push) Has been cancelled
2026-08-20 14:20:13 +01:00
cdbdcc5832 Refactor org backend and remove obsolete code
All checks were successful
Build Org Backend / build (push) Successful in 15s
2026-07-21 13:38:25 +01:00
016b9dcd81 resource
All checks were successful
Build Org Backend / build (push) Successful in 24s
2026-06-26 14:45:13 +01:00
cebdf72635 nostalgia integration
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-06-26 12:36:09 +01:00
478484ce76 period
All checks were successful
Build Org Backend / build (push) Successful in 20s
2026-06-26 12:28:46 +01:00
78515fb468 Add period companion wellbeing tools
All checks were successful
Build Org Backend / build (push) Successful in 19s
2026-06-23 10:07:40 +01:00
e4f175526f Project period predictions across calendar ranges
All checks were successful
Build Org Backend / build (push) Successful in 15s
2026-06-22 17:10:57 +01:00
7bae39fe62 Add configurable period prediction settings
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-06-22 17:01:10 +01:00
5ab5b05467 Add period companion backend
All checks were successful
Build Org Backend / build (push) Successful in 11s
2026-06-22 16:27:38 +01:00
118bc4e586 updates for backend
All checks were successful
Build Org Backend / build (push) Successful in 15s
2026-06-17 20:52:36 +01:00
4bf7810206 updates for zmanagement x4
All checks were successful
Build Org Backend / build (push) Successful in 17s
2026-06-10 16:40:53 +01:00
28d874edd8 updates for zmanagement x3
All checks were successful
Build Org Backend / build (push) Successful in 15s
2026-06-10 16:30:21 +01:00
3712e2e6f0 updates for zmanagement x2
All checks were successful
Build Org Backend / build (push) Successful in 23s
2026-06-10 15:57:18 +01:00
9df3e162ce updates for zmanagement
All checks were successful
Build Org Backend / build (push) Successful in 20s
2026-06-10 12:47:57 +01:00
e9914e7fa8 updates
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-06-04 14:32:21 +01:00
3b504aeea8 01/06
All checks were successful
Build Org Backend / build (push) Successful in 13s
2026-06-01 08:58:25 +01:00
bfb3d976f0 clean
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-05-10 00:38:02 +01:00
888fb3ff26 rpg fleshing out x5
All checks were successful
Build Org Backend / build (push) Successful in 11s
2026-05-09 23:27:58 +01:00
b7ef8fc870 rpg initial commit
All checks were successful
Build Org Backend / build (push) Successful in 10s
2026-05-09 22:38:21 +01:00
0e5f8d7b1c Revert "auth"
All checks were successful
Build Org Backend / build (push) Successful in 10s
This reverts commit c8937357e5.
2026-05-09 01:19:43 +01:00
6dac9e7cc1 Revert "auth"
This reverts commit aa31cc37d9.
2026-05-09 01:19:43 +01:00
aa31cc37d9 auth
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-05-09 00:36:49 +01:00
c8937357e5 auth
All checks were successful
Build Org Backend / build (push) Successful in 18s
2026-05-09 00:26:41 +01:00
efd096bb9a testing the systemd works
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-05-09 00:12:15 +01:00
8077a1a94e FIx
Some checks failed
Build Org Backend / build (push) Failing after 11s
2026-05-09 00:08:32 +01:00
a2eef01038 s
Some checks failed
Build Org Backend / build (push) Failing after 15s
2026-05-09 00:03:57 +01:00
340b33f5d0 check
Some checks failed
Build Org Backend / build (push) Failing after 15s
2026-05-09 00:02:29 +01:00
2f7d018c70 password
All checks were successful
Build Org Backend / build (push) Successful in 12s
2026-05-08 23:57:56 +01:00
041acd663e cahnges
Some checks failed
Build Org Backend / build (push) Failing after 11s
2026-05-08 23:55:52 +01:00
e0eab23692 yml
Some checks failed
Build Org Backend / build (push) Has been cancelled
2026-05-08 23:47:41 +01:00
fe8a911479 Initial pipeline 2026-05-08 23:46:34 +01:00
1c6f69c752 changes 2026-05-08 23:35:55 +01:00
f75548d08e Cleanup 2026-03-22 00:19:26 +00:00
81375a1acb wird updates 2026-03-21 17:37:22 +00:00
e04d6f98d1 fixing the output logs 2026-03-20 23:53:07 +00:00
7fa7cbb4c7 incremental security changes 2026-03-20 09:35:40 +00:00
b21698dec9 Wird additions 2026-03-19 17:39:16 +00:00
zaine
c5bb58888a updating 2026-03-19 17:30:04 +00:00
4a2171c38d updating the competencies 2026-03-08 16:37:57 +00:00
a225ab2993 using claude to spice things up 2026-03-08 13:26:03 +00:00
1ec9558bf7 Major changes 2026-02-22 22:09:12 +00:00
4b7b367730 Merge pull request 'Completed base tests for comments' (#2) from testing-base into master
Reviewed-on: #2
2026-01-04 15:52:17 +00:00
478c4b93a9 Completed base tests for comments 2026-01-04 15:50:53 +00:00
9210fef76e completed tests for competency service 2026-01-04 15:39:36 +00:00
115 changed files with 5881 additions and 414 deletions

39
.gitea/workflows/build.yml Executable file
View 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

3
.gitignore vendored Normal file → Executable file
View File

@@ -1,6 +1,9 @@
*~ *~
target/ target/
application.properties application.properties
application-*.properties
!src/main/resources/application-dev.properties
!src/main/resources/application-prod.properties
.settings/ .settings/
.project .project
.classpath .classpath

0
.mvn/jvm.config Normal file → Executable file
View File

0
.mvn/maven.config Normal file → Executable file
View File

0
Makefile Normal file → Executable file
View File

32
README.md Normal file → Executable file
View File

@@ -8,3 +8,35 @@ This repository contains the source code and documentation for all of my APIs. I
The repository is organised into the following main directories: The repository is organised into the following main directories:
# 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.

View 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

View File

@@ -0,0 +1 @@
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service

View File

@@ -0,0 +1 @@
/home/zaine/.cache/act/2b629be6fbcb71eb/hostexecutor/misc/org-backend.service

View 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
View File

@@ -0,0 +1,2 @@
library.json
thumbnails/

View 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."
}
}
}

11
Dockerfile → misc/Dockerfile Normal file → Executable file
View File

@@ -9,9 +9,20 @@ RUN mvn -B dependency:go-offline
COPY src ./src COPY src ./src
RUN mvn -B clean package -DskipTests RUN mvn -B clean package -DskipTests
# -------- Runtime stage -------- # -------- Runtime stage --------
FROM eclipse-temurin:21-jre 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 WORKDIR /app
COPY --from=build /app/target/*.jar app.jar COPY --from=build /app/target/*.jar app.jar

51
misc/org-backend.env.example Executable file
View 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
View 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

42
pom.xml Normal file → Executable file
View File

@@ -42,7 +42,17 @@
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <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> </dependency>
<!-- Testing --> <!-- Testing -->
@@ -58,9 +68,39 @@
<version>2.0.2</version> <version>2.0.2</version>
</dependency> </dependency>
<!-- JWT Dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson, jjwt-orgjson -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.mnode.ical4j</groupId>
<artifactId>ical4j</artifactId>
<version>4.2.5</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
<finalName>org-backend</finalName>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

70
scripts/deploy-systemd.sh Executable file
View 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
View File

@@ -2,9 +2,11 @@ package org.zaine.app;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class Application { public class Application {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(Application.class, args); SpringApplication.run(Application.class, args);

View 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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View 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;
}
}

View 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);
}
}

View 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; }
}

View 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();
}
}

View 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)
);
}
}

View 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();
}
}

View 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) {}
}

View 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());
}
}

View File

@@ -1,9 +1,8 @@
package org.zaine.app.controller; package org.zaine.app.controller;
import java.lang.System.Logger;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -14,58 +13,107 @@ import org.zaine.app.dto.CreateCommentDTO;
import org.zaine.app.model.Comments; import org.zaine.app.model.Comments;
import org.zaine.app.service.CommentsService; 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 @RestController
@RequestMapping("/api/comments") @RequestMapping("/api/comments")
@Tag(name = "Comments", description = "Endpoints for managing comments")
public class CommentsController { public class CommentsController {
private static final Logger logger = System.getLogger(CommentsController.class.getName());
@Autowired
private CommentsService commentsService;
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}") @GetMapping("/{page_slug}")
public List<Comments> getAllCommentsBySlug(@PathVariable String page_slug) { public List<CommentResponse> getAllCommentsBySlug(@PathVariable String page_slug) {
List<Comments> allComments = commentsService.getAllCommentsBySlug(page_slug); return commentsService.getAllCommentsBySlug(page_slug).stream().map(CommentResponse::from).toList();
return allComments;
} }
@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}") @GetMapping("/item/{id}")
public Comments getCommentById(@PathVariable Integer id) { public CommentResponse getCommentById(@PathVariable Integer id) {
return commentsService.getCommentById(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("") @GetMapping("")
public List<Comments> getAllComments() { public List<CommentResponse> getAllComments() {
return commentsService.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( @PostMapping(
value = "", value = "",
consumes = "application/json" consumes = "application/json"
) )
public void addComment(@RequestBody CreateCommentDTO dto) { public void addComment(@RequestBody CreateCommentDTO dto) {
if (dto.getContent() == null || dto.getContent().trim().isEmpty()) { commentsService.addComment(dto.getPageSlug(), dto.getAuthor(), dto.getContent(), dto.getParentId());
logger.log(System.Logger.Level.WARNING,
"Attempted to add empty comment. Operation aborted.");
return;
}
Comments newComment = new Comments();
newComment.setPageSlug(dto.getPageSlug());
newComment.setAuthor(dto.getAuthor());
newComment.setContent(dto.getContent());
newComment.setParentId(dto.getParentId());
newComment.setCreatedAt(java.time.Instant.now());
logger.log(System.Logger.Level.INFO,
"Adding comment to pageSlug: " + dto.getPageSlug());
commentsService.addComment(newComment);
} }
@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}") @GetMapping("/thread/{comment_id}")
public List<Comments> getCommentThread(@PathVariable Integer comment_id) { public List<CommentResponse> getCommentThread(@PathVariable Integer comment_id) {
return commentsService.getCommentThread(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());
}
} }
} }

View File

@@ -1,40 +1,74 @@
package org.zaine.app.controller; package org.zaine.app.controller;
import java.lang.System.Logger;
import java.util.List; import java.util.List;
import java.util.Map;
import org.zaine.app.dto.CompetenciesDTO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody; 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.springframework.web.bind.annotation.RestController;
import org.zaine.app.dto.CompetenciesDTO;
import org.zaine.app.model.Competencies; import org.zaine.app.model.Competencies;
import org.zaine.app.service.CompetenciesService; 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 @RestController
@RequestMapping("/api/competencies") @RequestMapping("/api/competencies")
@Tag(name = "Competencies", description = "Operations about competencies")
public class CompetenciesController { public class CompetenciesController {
private static final Logger logger = System.getLogger(CompetenciesController.class.getName()); private final CompetenciesService competenciesService;
@Autowired
private CompetenciesService competenciesService;
@GetMapping("/items") public CompetenciesController(CompetenciesService competenciesService) {
public List<Competencies> getAllCompetencies() { this.competenciesService = competenciesService;
return competenciesService.getAllCompetencies();
}
@GetMapping("/item/{id}")
public Competencies getCompetencyById(@PathVariable Integer id) {
return competenciesService.getCompetencyById(id);
} }
@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( @PostMapping(
value = "/items/{id}/state", value = "/items/{id}/state",
consumes = "application/json" consumes = "application/json"
@@ -46,4 +80,11 @@ public class CompetenciesController {
competenciesService.updateCompetencyState(id, request.getState()); 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());
}
}
} }

View File

@@ -1,13 +0,0 @@
package org.zaine.app.controller;
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";
}
}

View File

@@ -1,44 +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.dto.CreateNoteDTO;
import org.zaine.app.model.Notes;
import org.zaine.app.service.NotesService;
import java.util.List;
@RestController
@RequestMapping("/api")
public class NotesController {
@Autowired
private NotesService notesService;
@GetMapping("/notes")
public List<Notes> getAllNotes() {
return notesService.getAllNotes();
}
@PostMapping(
value = "/notes",
consumes = "application/json"
)
public Notes createNote(@RequestBody CreateNoteDTO dto) {
Notes note = new Notes();
note.setAuthorName(dto.getAuthorName());
note.setContent(dto.getContent());
return notesService.createNote(note);
}
}

View 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();
}
}

View 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());
}
}
}

View 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("&", "&amp;") // must be first
.replace("<", "&lt;")
.replace(">", "&gt;");
}
/* =========================================================
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;
}
}
}

View 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);
}

View 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();
}
}

View 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) {
}

View 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) {
}

0
src/main/java/org/zaine/app/dto/CompetenciesDTO.java Normal file → Executable file
View File

View 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) {
}

0
src/main/java/org/zaine/app/dto/CreateCommentDTO.java Normal file → Executable file
View File

View File

@@ -1,29 +0,0 @@
package org.zaine.app.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
public class CreateNoteDTO {
@JsonProperty("author_name")
private String authorName;
private String content;
public CreateNoteDTO() {}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}

View 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; }
}

View 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;
}
}

View 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; }
}

View 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()
)
);
}
}

View 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; }
}

0
src/main/java/org/zaine/app/model/Comments.java Normal file → Executable file
View File

0
src/main/java/org/zaine/app/model/Competencies.java Normal file → Executable file
View File

View 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());
}
}

View 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; }
}

View File

@@ -1,64 +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 java.time.Instant;
import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonProperty;
@Entity
@Table(name = "public_notes")
public class Notes {
@Id
@GeneratedValue
@Column(name="id")
private UUID id;
@Column(name="content")
private String content;
@JsonProperty("author_name")
@Column(name="author_name")
private String authorName;
@JsonProperty("created_at")
@Column(name="created_at")
private Instant createdAt;
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 Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View 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;
}
}

View 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; }
}

View 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());
}
}
}

View 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; }
}

View File

@@ -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());
}
}

View File

@@ -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> {}

View 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);
}

View File

@@ -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);
}

View File

@@ -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));
}
}

View 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);
}
}

View 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);
}

View File

View File

@@ -1,9 +1,10 @@
package org.zaine.app.repositories; package org.zaine.app.repositories;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.zaine.app.model.Competencies; import org.zaine.app.model.Competencies;
public interface CompetenciesRepository extends JpaRepository<Competencies, Integer> { public interface CompetenciesRepository extends JpaRepository<Competencies, Integer> {
List<Competencies> findByGroup(String group);
} }

View 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);
}

View File

@@ -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> {
}

View 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> {
}

View 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);
}

View 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.");
}
}

View 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();
}
}

View 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) {}
}

View 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();
}
}

View 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();
}
}

View 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) {}
}

View 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();
}
}

View 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) {
}
}

View File

@@ -1,15 +1,18 @@
package org.zaine.app.service; package org.zaine.app.service;
import java.lang.System.Logger;
import java.util.List; import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.zaine.app.model.Comments; import org.zaine.app.model.Comments;
import org.zaine.app.repositories.CommentsRepository; import org.zaine.app.repositories.CommentsRepository;
import org.zaine.app.common.application.ApplicationException;
import java.util.stream.*; import java.util.stream.*;
import java.time.Instant;
@Service @Service
public class CommentsService { public class CommentsService {
private static final Logger logger = System.getLogger(CommentsService.class.getName()); private static final Logger log = LoggerFactory.getLogger(CommentsService.class);
private final CommentsRepository commentsRepository; private final CommentsRepository commentsRepository;
@@ -22,7 +25,7 @@ public class CommentsService {
} }
public List<Comments> getAllCommentsBySlug(String page_slug) { public List<Comments> getAllCommentsBySlug(String page_slug) {
logger.log(System.Logger.Level.INFO, "Entering getAllCommentsBySlug with slug: " + page_slug); log.debug("Finding comments for page slug {}", page_slug);
List<Comments> allComments = commentsRepository.findAll(); List<Comments> allComments = commentsRepository.findAll();
List<Comments> filteredComments = allComments.stream() List<Comments> filteredComments = allComments.stream()
.filter(comment -> page_slug.equals(comment.getPageSlug())) .filter(comment -> page_slug.equals(comment.getPageSlug()))
@@ -31,24 +34,32 @@ public class CommentsService {
} }
public Comments getCommentById(Integer id) { public Comments getCommentById(Integer id) {
logger.log(System.Logger.Level.INFO, "Entering getCommentById with id: " + id); if (id == null) {
return commentsRepository.findById(id).get(); log.warn("Comment lookup skipped because id was null");
return null;
}
return commentsRepository.findById(id)
.orElseThrow(() -> ApplicationException.notFound("Comment not found"));
} }
public void addComment(Comments comment) { public Comments addComment(String pageSlug, String author, String content, Integer parentId) {
if (comment == null) { if (content == null || content.trim().isEmpty()) {
logger.log(System.Logger.Level.WARNING, "Comment is null. Operation aborted."); log.warn("Comment creation skipped because content was empty");
return; return null;
} else {
logger.log(System.Logger.Level.INFO, "Adding comment with id: " + comment.getId());
commentsRepository.save(comment);
logger.log(System.Logger.Level.INFO, "Added new comment with id: " + comment.getId());
} }
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) { public List<Comments> getCommentThread(Integer comment_id) {
logger.log(System.Logger.Level.INFO, "Entering getCommentThread with id: " + comment_id); log.debug("Finding comment thread for id {}", comment_id);
return commentsRepository.findCommentThread(comment_id); return commentsRepository.findCommentThread(comment_id);
} }
} }

View File

@@ -1,52 +1,49 @@
package org.zaine.app.service; package org.zaine.app.service;
import java.lang.System.Logger;
import java.util.List; import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.zaine.app.model.Competencies; import org.zaine.app.model.Competencies;
import org.zaine.app.repositories.CompetenciesRepository; import org.zaine.app.repositories.CompetenciesRepository;
import org.zaine.app.common.application.ApplicationException;
@Service @Service
public class CompetenciesService { 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");
private static final Logger logger = System.getLogger(CompetenciesService.class.getName()); public CompetenciesService(CompetenciesRepository competenciesRepository) {
this.competenciesRepository = competenciesRepository;
}
private final CompetenciesRepository competenciesRepository; public List<Competencies> getAllCompetencies() {
return competenciesRepository.findAll();
}
private static final List<String> ALLOWED_STATES = public List<Competencies> getCompetenciesByGroup(String group) {
List.of("completed", "manager_review", "in_progress", "not_started", "comments"); 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 CompetenciesService(CompetenciesRepository competenciesRepository) { public void updateCompetencyState(Integer id, String newState) {
this.competenciesRepository = competenciesRepository; if (newState == null) {
} throw new IllegalArgumentException("State must not be null");
}
public List<Competencies> getAllCompetencies() { if (!ALLOWED_STATES.contains(newState)) {
return competenciesRepository.findAll(); throw new IllegalArgumentException("Invalid state: " + newState);
}
} Competencies competency = getCompetencyById(id);
competency.setState(newState);
public Competencies getCompetencyById(Integer id) { competenciesRepository.save(competency);
}
logger.log(System.Logger.Level.INFO, "Entering getCompetencyById with id: " + id);
return competenciesRepository.findById(id).get();
}
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);
}
} }

View 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);
}
}

View File

@@ -1,29 +0,0 @@
package org.zaine.app.service;
import java.util.List;
import java.util.logging.Logger;
import org.springframework.stereotype.Service;
import org.zaine.app.model.Notes;
import org.zaine.app.repositories.NotesRepository;
@Service
public class NotesService {
private static final Logger logger = Logger.getLogger(NotesService.class.getName());
private final NotesRepository notesRepository;
public NotesService(NotesRepository notesRepository) {
this.notesRepository = notesRepository;
}
public List<Notes> getAllNotes() {
return notesRepository.findAll();
}
public Notes createNote(Notes note) {
logger.info("Creating note with content: " + note.getContent());
return notesRepository.save(note);
}
}

View 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();
}
}

View 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");
}
}
}

View 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");
}
}

View 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"
)
);
}
}

View 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");
}
}
}

View 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; }
}

View 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()))
);
}
}

View 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);
}

View 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

View 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

View 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()
);

View 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()
);

View 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))
);

View 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;

View 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;

Some files were not shown because too many files have changed in this diff Show More