16 Commits

Author SHA1 Message Date
548342798e Use Lombok plugin
All checks were successful
Backend Build and Test / build (push) Successful in 39s
2026-03-02 18:12:10 +01:00
e2ac1bfd3d Split API controller 2026-03-02 18:10:01 +01:00
03aa050f6a Actually add lombok support 2026-03-02 18:09:38 +01:00
6beba890e8 Ignore more eclipse stuff 2026-03-02 18:09:26 +01:00
94380db02d Use lombok @getter @setter 2026-03-02 18:09:08 +01:00
b3410e3a5f Add Eclipse files to gitignore 2026-03-02 18:08:47 +01:00
e316d99453 Reformat code
All checks were successful
Backend Build and Test / build (push) Successful in 40s
2026-03-01 16:23:15 +01:00
a08a462e22 Add data migration export/import endpoints
All checks were successful
Backend Build and Test / build (push) Successful in 40s
Add GET /api/export to the Go backend that dumps all users, allowances,
history, and tasks (including completed) as a single JSON snapshot.

Add POST /api/import to the Spring backend that accepts the same JSON,
wipes existing data, inserts all records with original IDs preserved via
native SQL, and resets PostgreSQL sequences to avoid future collisions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 16:19:55 +01:00
29284f6eac Add spring backend 2026-03-01 16:19:55 +01:00
ccc8d5e8e7 Update .gitea/workflows/deploy.yml (#146)
All checks were successful
Backend Build and Test / build (push) Successful in 1m3s
Backend Deploy / build (push) Successful in 5m7s
Reviewed-on: #146
2026-02-14 16:16:08 +01:00
530939df79 Update .gitea/workflows/deploy.yml (#145)
All checks were successful
Backend Build and Test / build (push) Successful in 1m28s
Backend Deploy / build (push) Successful in 1m52s
Reviewed-on: #145
2026-02-14 16:03:33 +01:00
07536fbcb0 Update .gitea/workflows/build.yml (#144)
Some checks failed
Backend Deploy / build (push) Has been cancelled
Backend Build and Test / build (push) Has been cancelled
Reviewed-on: #144
2026-02-14 16:03:24 +01:00
c9a96f937a transfer-allowances (#143)
All checks were successful
Backend Build and Test / build (push) Successful in 10m9s
Backend Deploy / build (push) Successful in 11m20s
Reviewed-on: #143
2025-10-08 20:21:09 +02:00
Huffle
cdbac17215 Update README.md (#142)
All checks were successful
Backend Build and Test / build (push) Successful in 3m22s
Backend Deploy / build (push) Successful in 15s
Reviewed-on: #142
2025-06-26 09:12:49 +02:00
Huffle
ecd43906ce AP-139 (#141)
Some checks failed
Backend Build and Test / build (push) Successful in 3m40s
Backend Deploy / build (push) Has been cancelled
Reviewed-on: #141
2025-06-26 09:08:57 +02:00
Huffle
d6935d2f54 Update README.md (#140)
All checks were successful
Backend Build and Test / build (push) Successful in 3m58s
Backend Deploy / build (push) Successful in 22s
Add backend links

Reviewed-on: #140
2025-06-26 08:51:15 +02:00
65 changed files with 3903 additions and 166 deletions

View File

@@ -3,7 +3,7 @@ on: [push]
jobs:
build:
runs-on: standard-latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -6,7 +6,7 @@ on:
jobs:
build:
runs-on: standard-latest
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -19,9 +19,12 @@ jobs:
- name: Build
run: |
cd backend
docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD) .
docker build -t gitea.seeseepuff.be/seeseemelk/allowance-planner:latest .
- name: Push
run: |
cd backend
docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:$(git rev-parse --short HEAD)
docker push gitea.seeseepuff.be/seeseemelk/allowance-planner:latest
- name: Trigger watchtower
uses: https://gitea.seeseepuff.be/actions/watchtower@master

View File

@@ -14,3 +14,19 @@ In order to run the frontend, go to the `allowance-planner-v2` directory in the
```bash
$ ionic serve
```
## Running frontend
In order to build the frontend for android, go to the `allowance-planner-v2` directory in the `frontend` directory and run:
```bash
$ ionic capacitor build android
```
## Backend links
```bash
Main: https://allowanceplanner.seeseepuff.be/api
```
```bash
Test: http://localhost:8080/api
```

8
backend-spring/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
# Eclispe Directories
/.classpath
/.project
/bin
/.settings

17
backend-spring/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM eclipse-temurin:25-jdk-alpine AS build
WORKDIR /app
COPY gradle ./gradle
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN ./gradlew dependencies --no-daemon
COPY src ./src
RUN ./gradlew bootJar --no-daemon -x test
FROM eclipse-temurin:25-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,38 @@
plugins {
java
id("org.springframework.boot") version "4.0.3"
id("io.spring.dependency-management") version "1.1.7"
id("io.freefair.lombok") version "9.2.0"
}
group = "be.seeseepuff"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.3")
testImplementation("org.testcontainers:testcontainers-postgresql:2.0.3")
testImplementation("io.rest-assured:rest-assured:6.0.0")
}
tasks.withType<Test> {
useJUnitPlatform()
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
backend-spring/gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
backend-spring/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1 @@
rootProject.name = "allowance-planner"

View File

@@ -0,0 +1,11 @@
package be.seeseepuff.allowanceplanner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AllowancePlannerApplication {
static void main(String[] args) {
SpringApplication.run(AllowancePlannerApplication.class, args);
}
}

View File

@@ -0,0 +1,221 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.AllowanceService;
import be.seeseepuff.allowanceplanner.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class AllowanceController {
private final UserService userService;
private final AllowanceService allowanceService;
public AllowanceController(UserService userService, AllowanceService allowanceService) {
this.userService = userService;
this.allowanceService = allowanceService;
}
@GetMapping("/user/{userId}/allowance")
public ResponseEntity<?> getUserAllowance(@PathVariable String userId) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
if (!userService.userExists(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
return ResponseEntity.ok(allowanceService.getUserAllowances(id));
}
@PostMapping("/user/{userId}/allowance")
public ResponseEntity<?> createUserAllowance(@PathVariable String userId,
@RequestBody CreateAllowanceRequest request) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
if (request.name() == null || request.name().isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Allowance name cannot be empty"));
}
if (!userService.userExists(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
int allowanceId = allowanceService.createAllowance(id, request);
return ResponseEntity.status(HttpStatus.CREATED).body(new IdResponse(allowanceId));
}
@PutMapping("/user/{userId}/allowance")
public ResponseEntity<?> bulkPutUserAllowance(@PathVariable String userId,
@RequestBody List<BulkUpdateAllowanceRequest> requests) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
if (!userService.userExists(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
allowanceService.bulkUpdateAllowance(id, requests);
return ResponseEntity.ok(new MessageResponse("Allowance updated successfully"));
}
@GetMapping("/user/{userId}/allowance/{allowanceId}")
public ResponseEntity<?> getUserAllowanceById(@PathVariable String userId, @PathVariable String allowanceId) {
int uid;
try {
uid = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
int aid;
try {
aid = Integer.parseInt(allowanceId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid allowance ID"));
}
if (!userService.userExists(uid)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
Optional<AllowanceDto> allowance = allowanceService.getUserAllowanceById(uid, aid);
if (allowance.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Allowance not found"));
}
return ResponseEntity.ok(allowance.get());
}
@DeleteMapping("/user/{userId}/allowance/{allowanceId}")
public ResponseEntity<?> deleteUserAllowance(@PathVariable String userId, @PathVariable String allowanceId) {
int uid;
try {
uid = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
int aid;
try {
aid = Integer.parseInt(allowanceId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid allowance ID"));
}
if (aid == 0) {
return ResponseEntity.badRequest().body(new ErrorResponse("Allowance id zero cannot be deleted"));
}
if (!userService.userExists(uid)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
boolean deleted = allowanceService.deleteAllowance(uid, aid);
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("History not found"));
}
return ResponseEntity.ok(new MessageResponse("History deleted successfully"));
}
@PutMapping("/user/{userId}/allowance/{allowanceId}")
public ResponseEntity<?> putUserAllowance(@PathVariable String userId, @PathVariable String allowanceId,
@RequestBody UpdateAllowanceRequest request) {
int uid;
try {
uid = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
int aid;
try {
aid = Integer.parseInt(allowanceId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid allowance ID"));
}
if (!userService.userExists(uid)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
boolean updated = allowanceService.updateAllowance(uid, aid, request);
if (!updated) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Allowance not found"));
}
return ResponseEntity.ok(new MessageResponse("Allowance updated successfully"));
}
@PostMapping("/user/{userId}/allowance/{allowanceId}/complete")
public ResponseEntity<?> completeAllowance(@PathVariable String userId, @PathVariable String allowanceId) {
int uid;
try {
uid = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
int aid;
try {
aid = Integer.parseInt(allowanceId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid allowance ID"));
}
if (!userService.userExists(uid)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
boolean completed = allowanceService.completeAllowance(uid, aid);
if (!completed) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Allowance not found"));
}
return ResponseEntity.ok(new MessageResponse("Allowance completed successfully"));
}
@PostMapping("/user/{userId}/allowance/{allowanceId}/add")
public ResponseEntity<?> addToAllowance(@PathVariable String userId, @PathVariable String allowanceId,
@RequestBody AddAllowanceAmountRequest request) {
int uid;
try {
uid = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
int aid;
try {
aid = Integer.parseInt(allowanceId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid allowance ID"));
}
if (!userService.userExists(uid)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
boolean result = allowanceService.addAllowanceAmount(uid, aid, request);
if (!result) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Allowance not found"));
}
return ResponseEntity.ok(new MessageResponse("Allowance completed successfully"));
}
}

View File

@@ -0,0 +1,57 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.AllowanceService;
import be.seeseepuff.allowanceplanner.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class HistoryController {
private final UserService userService;
private final AllowanceService allowanceService;
public HistoryController(UserService userService, AllowanceService allowanceService) {
this.userService = userService;
this.allowanceService = allowanceService;
}
@PostMapping("/user/{userId}/history")
public ResponseEntity<?> postHistory(@PathVariable String userId, @RequestBody PostHistoryRequest request) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
if (request.description() == null || request.description().isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Description cannot be empty"));
}
if (!userService.userExists(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
allowanceService.addHistory(id, request);
return ResponseEntity.ok(new MessageResponse("History updated successfully"));
}
@GetMapping("/user/{userId}/history")
public ResponseEntity<?> getHistory(@PathVariable String userId) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
List<HistoryDto> history = allowanceService.getHistory(id);
return ResponseEntity.ok(history);
}
}

View File

@@ -0,0 +1,23 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.MigrationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class MigrationController {
private final MigrationService migrationService;
public MigrationController(MigrationService migrationService) {
this.migrationService = migrationService;
}
@PostMapping("/import")
public ResponseEntity<?> importData(@RequestBody MigrationDto data) {
migrationService.importData(data);
return ResponseEntity.ok(new MessageResponse("Import successful"));
}
}

View File

@@ -0,0 +1,116 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.TaskService;
import be.seeseepuff.allowanceplanner.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class TaskController {
private final UserService userService;
private final TaskService taskService;
public TaskController(UserService userService, TaskService taskService) {
this.userService = userService;
this.taskService = taskService;
}
@PostMapping("/tasks")
public ResponseEntity<?> createTask(@RequestBody CreateTaskRequest request) {
if (request.name() == null || request.name().isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Task name cannot be empty"));
}
if (request.schedule() != null) {
return ResponseEntity.badRequest().body(new ErrorResponse("Schedules are not yet supported"));
}
if (request.assigned() != null) {
if (!userService.userExists(request.assigned())) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
}
int taskId = taskService.createTask(request);
return ResponseEntity.status(HttpStatus.CREATED).body(new IdResponse(taskId));
}
@GetMapping("/tasks")
public List<TaskDto> getTasks() {
return taskService.getTasks();
}
@GetMapping("/task/{taskId}")
public ResponseEntity<?> getTask(@PathVariable String taskId) {
int id;
try {
id = Integer.parseInt(taskId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid task ID"));
}
Optional<TaskDto> task = taskService.getTask(id);
if (task.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Task not found"));
}
return ResponseEntity.ok(task.get());
}
@PutMapping("/task/{taskId}")
public ResponseEntity<?> putTask(@PathVariable String taskId, @RequestBody CreateTaskRequest request) {
int id;
try {
id = Integer.parseInt(taskId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid task ID"));
}
Optional<TaskDto> existing = taskService.getTask(id);
if (existing.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Task not found"));
}
taskService.updateTask(id, request);
return ResponseEntity.ok(new MessageResponse("Task updated successfully"));
}
@DeleteMapping("/task/{taskId}")
public ResponseEntity<?> deleteTask(@PathVariable String taskId) {
int id;
try {
id = Integer.parseInt(taskId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid task ID"));
}
if (!taskService.hasTask(id)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Task not found"));
}
taskService.deleteTask(id);
return ResponseEntity.ok(new MessageResponse("Task deleted successfully"));
}
@PostMapping("/task/{taskId}/complete")
public ResponseEntity<?> completeTask(@PathVariable String taskId) {
int id;
try {
id = Integer.parseInt(taskId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid task ID"));
}
boolean completed = taskService.completeTask(id);
if (!completed) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Task not found"));
}
return ResponseEntity.ok(new MessageResponse("Task completed successfully"));
}
}

View File

@@ -0,0 +1,28 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.TransferService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class TransferController {
private final TransferService transferService;
public TransferController(TransferService transferService) {
this.transferService = transferService;
}
@PostMapping("/transfer")
public ResponseEntity<?> transfer(@RequestBody TransferRequest request) {
TransferService.TransferResult result = transferService.transfer(request);
return switch (result.status()) {
case SUCCESS -> ResponseEntity.ok(new MessageResponse(result.message()));
case BAD_REQUEST -> ResponseEntity.badRequest().body(new ErrorResponse(result.message()));
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(result.message()));
};
}
}

View File

@@ -0,0 +1,42 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api")
@CrossOrigin(origins = "*")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/users")
public List<UserDto> getUsers() {
return userService.getUsers();
}
@GetMapping("/user/{userId}")
public ResponseEntity<?> getUser(@PathVariable String userId) {
int id;
try {
id = Integer.parseInt(userId);
} catch (NumberFormatException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid user ID"));
}
Optional<UserWithAllowanceDto> user = userService.getUser(id);
if (user.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found"));
}
return ResponseEntity.ok(user.get());
}
}

View File

@@ -0,0 +1,149 @@
package be.seeseepuff.allowanceplanner.controller;
import be.seeseepuff.allowanceplanner.dto.CreateAllowanceRequest;
import be.seeseepuff.allowanceplanner.dto.CreateTaskRequest;
import be.seeseepuff.allowanceplanner.service.AllowanceService;
import be.seeseepuff.allowanceplanner.service.TaskService;
import be.seeseepuff.allowanceplanner.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Arrays;
@Controller
public class WebController {
private final UserService userService;
private final AllowanceService allowanceService;
private final TaskService taskService;
public WebController(UserService userService, AllowanceService allowanceService, TaskService taskService) {
this.userService = userService;
this.allowanceService = allowanceService;
this.taskService = taskService;
}
@GetMapping("/")
public String index(HttpServletRequest request, HttpServletResponse response, Model model) {
Integer currentUser = getCurrentUser(request, response);
if (currentUser == null) {
model.addAttribute("users", userService.getUsers());
return "index";
}
return renderWithUser(model, currentUser);
}
@GetMapping("/login")
public String login(@RequestParam(required = false) String user, HttpServletResponse response) {
if (user != null && !user.isEmpty()) {
Cookie cookie = new Cookie("user", user);
cookie.setMaxAge(3600);
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
return "redirect:/";
}
@PostMapping("/createTask")
public String createTask(@RequestParam String name, @RequestParam double reward,
@RequestParam(required = false) String schedule,
HttpServletRequest request, HttpServletResponse response, Model model) {
Integer currentUser = getCurrentUser(request, response);
if (currentUser == null) {
return "redirect:/";
}
if (name.isEmpty() || reward <= 0) {
model.addAttribute("error", "Invalid input");
return "index";
}
CreateTaskRequest taskRequest = new CreateTaskRequest(name, reward, null,
(schedule != null && !schedule.isEmpty()) ? schedule : null);
taskService.createTask(taskRequest);
return "redirect:/";
}
@GetMapping("/completeTask")
public String completeTask(@RequestParam("task") int taskId) {
taskService.completeTask(taskId);
return "redirect:/";
}
@PostMapping("/createAllowance")
public String createAllowance(@RequestParam String name, @RequestParam double target,
@RequestParam double weight,
HttpServletRequest request, HttpServletResponse response, Model model) {
Integer currentUser = getCurrentUser(request, response);
if (currentUser == null) {
return "redirect:/";
}
if (name.isEmpty() || target <= 0 || weight <= 0) {
model.addAttribute("error", "Invalid input");
return "index";
}
allowanceService.createAllowance(currentUser, new CreateAllowanceRequest(name, target, weight, ""));
return "redirect:/";
}
@GetMapping("/completeAllowance")
public String completeAllowance(@RequestParam("allowance") int allowanceId,
HttpServletRequest request, HttpServletResponse response) {
Integer currentUser = getCurrentUser(request, response);
if (currentUser == null) {
return "redirect:/";
}
allowanceService.completeAllowance(currentUser, allowanceId);
return "redirect:/";
}
private Integer getCurrentUser(HttpServletRequest request, HttpServletResponse response) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
String userStr = Arrays.stream(cookies)
.filter(c -> "user".equals(c.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
if (userStr == null) {
return null;
}
try {
int userId = Integer.parseInt(userStr);
if (!userService.userExists(userId)) {
unsetUserCookie(response);
return null;
}
return userId;
} catch (NumberFormatException e) {
unsetUserCookie(response);
return null;
}
}
private void unsetUserCookie(HttpServletResponse response) {
Cookie cookie = new Cookie("user", "");
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
private String renderWithUser(Model model, int currentUser) {
model.addAttribute("users", userService.getUsers());
model.addAttribute("currentUser", currentUser);
model.addAttribute("allowances", allowanceService.getUserAllowances(currentUser));
model.addAttribute("tasks", taskService.getTasks());
model.addAttribute("history", allowanceService.getHistory(currentUser));
return "index";
}
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record AddAllowanceAmountRequest(double amount, String description) {
}

View File

@@ -0,0 +1,7 @@
package be.seeseepuff.allowanceplanner.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.ALWAYS)
public record AllowanceDto(int id, String name, double target, double progress, double weight, String colour) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record BulkUpdateAllowanceRequest(int id, double weight) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record CreateAllowanceRequest(String name, double target, double weight, String colour) {
}

View File

@@ -0,0 +1,8 @@
package be.seeseepuff.allowanceplanner.dto;
public record CreateTaskRequest(
String name,
Double reward,
Integer assigned,
String schedule) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record ErrorResponse(String error) {
}

View File

@@ -0,0 +1,6 @@
package be.seeseepuff.allowanceplanner.dto;
import java.time.Instant;
public record HistoryDto(double allowance, Instant timestamp, String description) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record IdResponse(int id) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record MessageResponse(String message) {
}

View File

@@ -0,0 +1,24 @@
package be.seeseepuff.allowanceplanner.dto;
import java.util.List;
public record MigrationDto(
List<MigrationUserDto> users,
List<MigrationAllowanceDto> allowances,
List<MigrationHistoryDto> history,
List<MigrationTaskDto> tasks
) {
public record MigrationUserDto(int id, String name, long balance, double weight) {
}
public record MigrationAllowanceDto(int id, int userId, String name, long target, long balance, double weight,
Integer colour) {
}
public record MigrationHistoryDto(int id, int userId, long timestamp, long amount, String description) {
}
public record MigrationTaskDto(int id, String name, long reward, Integer assigned, String schedule,
Long completed, Long nextRun) {
}
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record PostHistoryRequest(double allowance, String description) {
}

View File

@@ -0,0 +1,7 @@
package be.seeseepuff.allowanceplanner.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.ALWAYS)
public record TaskDto(int id, String name, double reward, Integer assigned, String schedule) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record TransferRequest(int from, int to, double amount) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record UpdateAllowanceRequest(String name, double target, double weight, String colour) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record UserDto(int id, String name) {
}

View File

@@ -0,0 +1,4 @@
package be.seeseepuff.allowanceplanner.dto;
public record UserWithAllowanceDto(int id, String name, double allowance) {
}

View File

@@ -0,0 +1,32 @@
package be.seeseepuff.allowanceplanner.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "allowances")
@Getter
@Setter
public class Allowance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(name = "user_id", nullable = false)
private int userId;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private long target;
@Column(nullable = false)
private long balance = 0;
@Column(nullable = false)
private double weight;
private Integer colour;
}

View File

@@ -0,0 +1,26 @@
package be.seeseepuff.allowanceplanner.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "history")
@Getter
@Setter
public class History {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(name = "user_id", nullable = false)
private int userId;
@Column(nullable = false)
private long timestamp;
@Column(nullable = false)
private long amount;
private String description;
}

View File

@@ -0,0 +1,30 @@
package be.seeseepuff.allowanceplanner.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "tasks")
@Getter
@Setter
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private long reward;
private Integer assigned;
private String schedule;
private Long completed;
@Column(name = "next_run")
private Long nextRun;
}

View File

@@ -0,0 +1,24 @@
package be.seeseepuff.allowanceplanner.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "users")
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private double weight = 10.0;
@Column(nullable = false)
private long balance = 0;
}

View File

@@ -0,0 +1,26 @@
package be.seeseepuff.allowanceplanner.repository;
import be.seeseepuff.allowanceplanner.entity.Allowance;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface AllowanceRepository extends JpaRepository<Allowance, Integer> {
List<Allowance> findByUserIdOrderByIdAsc(int userId);
Optional<Allowance> findByIdAndUserId(int id, int userId);
int countByIdAndUserId(int id, int userId);
void deleteByIdAndUserId(int id, int userId);
@Query("SELECT a FROM Allowance a WHERE a.userId = :userId AND a.weight > 0 ORDER BY (a.target - a.balance) ASC")
List<Allowance> findByUserIdWithPositiveWeightOrderByRemainingAsc(int userId);
@Query("SELECT COALESCE(SUM(a.weight), 0) FROM Allowance a WHERE a.userId = :userId AND a.weight > 0")
double sumPositiveWeights(int userId);
}

View File

@@ -0,0 +1,12 @@
package be.seeseepuff.allowanceplanner.repository;
import be.seeseepuff.allowanceplanner.entity.History;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface HistoryRepository extends JpaRepository<History, Integer> {
List<History> findByUserIdOrderByIdDesc(int userId);
}

View File

@@ -0,0 +1,15 @@
package be.seeseepuff.allowanceplanner.repository;
import be.seeseepuff.allowanceplanner.entity.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface TaskRepository extends JpaRepository<Task, Integer> {
List<Task> findByCompletedIsNull();
Optional<Task> findByIdAndCompletedIsNull(int id);
}

View File

@@ -0,0 +1,12 @@
package be.seeseepuff.allowanceplanner.repository;
import be.seeseepuff.allowanceplanner.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
@Query("SELECT COALESCE(SUM(h.amount), 0) FROM History h WHERE h.userId = :userId")
long sumHistoryAmount(int userId);
}

View File

@@ -0,0 +1,258 @@
package be.seeseepuff.allowanceplanner.service;
import be.seeseepuff.allowanceplanner.dto.*;
import be.seeseepuff.allowanceplanner.entity.Allowance;
import be.seeseepuff.allowanceplanner.entity.History;
import be.seeseepuff.allowanceplanner.entity.User;
import be.seeseepuff.allowanceplanner.repository.AllowanceRepository;
import be.seeseepuff.allowanceplanner.repository.HistoryRepository;
import be.seeseepuff.allowanceplanner.repository.UserRepository;
import be.seeseepuff.allowanceplanner.util.ColourUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class AllowanceService {
private final AllowanceRepository allowanceRepository;
private final UserRepository userRepository;
private final HistoryRepository historyRepository;
public AllowanceService(AllowanceRepository allowanceRepository,
UserRepository userRepository,
HistoryRepository historyRepository) {
this.allowanceRepository = allowanceRepository;
this.userRepository = userRepository;
this.historyRepository = historyRepository;
}
public List<AllowanceDto> getUserAllowances(int userId) {
User user = userRepository.findById(userId).orElseThrow();
List<AllowanceDto> result = new ArrayList<>();
// Add the "rest" allowance (id=0)
result.add(new AllowanceDto(0, "", 0, user.getBalance() / 100.0, user.getWeight(), ""));
// Add named allowances
for (Allowance a : allowanceRepository.findByUserIdOrderByIdAsc(userId)) {
result.add(toDto(a));
}
return result;
}
public Optional<AllowanceDto> getUserAllowanceById(int userId, int allowanceId) {
if (allowanceId == 0) {
return userRepository.findById(userId)
.map(u -> new AllowanceDto(0, "", 0, u.getBalance() / 100.0, u.getWeight(), ""));
}
return allowanceRepository.findByIdAndUserId(allowanceId, userId)
.map(this::toDto);
}
@Transactional
public int createAllowance(int userId, CreateAllowanceRequest request) {
int colour = ColourUtil.convertStringToColour(request.colour());
Allowance allowance = new Allowance();
allowance.setUserId(userId);
allowance.setName(request.name());
allowance.setTarget(Math.round(request.target() * 100.0));
allowance.setWeight(request.weight());
allowance.setColour(colour);
allowance = allowanceRepository.save(allowance);
return allowance.getId();
}
@Transactional
public boolean deleteAllowance(int userId, int allowanceId) {
int count = allowanceRepository.countByIdAndUserId(allowanceId, userId);
if (count == 0) {
return false;
}
allowanceRepository.deleteByIdAndUserId(allowanceId, userId);
return true;
}
@Transactional
public boolean updateAllowance(int userId, int allowanceId, UpdateAllowanceRequest request) {
if (allowanceId == 0) {
User user = userRepository.findById(userId).orElseThrow();
user.setWeight(request.weight());
userRepository.save(user);
return true;
}
Optional<Allowance> opt = allowanceRepository.findByIdAndUserId(allowanceId, userId);
if (opt.isEmpty()) {
return false;
}
int colour = ColourUtil.convertStringToColour(request.colour());
Allowance allowance = opt.get();
allowance.setName(request.name());
allowance.setTarget(Math.round(request.target() * 100.0));
allowance.setWeight(request.weight());
allowance.setColour(colour);
allowanceRepository.save(allowance);
return true;
}
@Transactional
public void bulkUpdateAllowance(int userId, List<BulkUpdateAllowanceRequest> requests) {
for (BulkUpdateAllowanceRequest req : requests) {
if (req.id() == 0) {
User user = userRepository.findById(userId).orElseThrow();
user.setWeight(req.weight());
userRepository.save(user);
} else {
allowanceRepository.findByIdAndUserId(req.id(), userId).ifPresent(a ->
{
a.setWeight(req.weight());
allowanceRepository.save(a);
});
}
}
}
@Transactional
public boolean completeAllowance(int userId, int allowanceId) {
Optional<Allowance> opt = allowanceRepository.findByIdAndUserId(allowanceId, userId);
if (opt.isEmpty()) {
return false;
}
Allowance allowance = opt.get();
long cost = allowance.getBalance();
String allowanceName = allowance.getName();
// Delete the allowance
allowanceRepository.delete(allowance);
// Add a history entry
History history = new History();
history.setUserId(userId);
history.setTimestamp(Instant.now().getEpochSecond());
history.setAmount(-cost);
history.setDescription("Allowance completed: " + allowanceName);
historyRepository.save(history);
return true;
}
@Transactional
public boolean addAllowanceAmount(int userId, int allowanceId, AddAllowanceAmountRequest request) {
long remainingAmount = Math.round(request.amount() * 100);
// Insert history entry
History history = new History();
history.setUserId(userId);
history.setTimestamp(Instant.now().getEpochSecond());
history.setAmount(remainingAmount);
history.setDescription(request.description());
historyRepository.save(history);
if (allowanceId == 0) {
if (remainingAmount < 0) {
User user = userRepository.findById(userId).orElseThrow();
if (remainingAmount > user.getBalance()) {
throw new IllegalArgumentException("cannot remove more than the current balance: " + user.getBalance());
}
}
User user = userRepository.findById(userId).orElseThrow();
user.setBalance(user.getBalance() + remainingAmount);
userRepository.save(user);
} else if (remainingAmount < 0) {
Allowance allowance = allowanceRepository.findByIdAndUserId(allowanceId, userId).orElse(null);
if (allowance == null) {
return false;
}
if (remainingAmount > allowance.getBalance()) {
throw new IllegalArgumentException("cannot remove more than the current allowance balance: " + allowance.getBalance());
}
allowance.setBalance(allowance.getBalance() + remainingAmount);
allowanceRepository.save(allowance);
} else {
Allowance allowance = allowanceRepository.findByIdAndUserId(allowanceId, userId).orElse(null);
if (allowance == null) {
return false;
}
long toAdd = remainingAmount;
if (allowance.getBalance() + toAdd > allowance.getTarget()) {
toAdd = allowance.getTarget() - allowance.getBalance();
}
remainingAmount -= toAdd;
if (toAdd > 0) {
allowance.setBalance(allowance.getBalance() + toAdd);
allowanceRepository.save(allowance);
}
if (remainingAmount > 0) {
addDistributedReward(userId, (int) remainingAmount);
}
}
return true;
}
public void addDistributedReward(int userId, int reward) {
User user = userRepository.findById(userId).orElseThrow();
double userWeight = user.getWeight();
double sumOfWeights = allowanceRepository.sumPositiveWeights(userId) + userWeight;
int remainingReward = reward;
if (sumOfWeights > 0) {
List<Allowance> allowances = allowanceRepository.findByUserIdWithPositiveWeightOrderByRemainingAsc(userId);
for (Allowance allowance : allowances) {
int amount = (int) ((allowance.getWeight() / sumOfWeights) * remainingReward);
if (allowance.getBalance() + amount > allowance.getTarget()) {
amount = (int) (allowance.getTarget() - allowance.getBalance());
}
sumOfWeights -= allowance.getWeight();
allowance.setBalance(allowance.getBalance() + amount);
allowanceRepository.save(allowance);
remainingReward -= amount;
}
}
// Add remaining to user's balance
user = userRepository.findById(userId).orElseThrow();
user.setBalance(user.getBalance() + remainingReward);
userRepository.save(user);
}
public List<HistoryDto> getHistory(int userId) {
return historyRepository.findByUserIdOrderByIdDesc(userId).stream()
.map(h -> new HistoryDto(
h.getAmount() / 100.0,
Instant.ofEpochSecond(h.getTimestamp()),
h.getDescription()))
.toList();
}
@Transactional
public void addHistory(int userId, PostHistoryRequest request) {
long amount = Math.round(request.allowance() * 100.0);
History history = new History();
history.setUserId(userId);
history.setTimestamp(Instant.now().getEpochSecond());
history.setAmount(amount);
history.setDescription(request.description());
historyRepository.save(history);
}
private AllowanceDto toDto(Allowance a) {
return new AllowanceDto(
a.getId(),
a.getName(),
a.getTarget() / 100.0,
a.getBalance() / 100.0,
a.getWeight(),
ColourUtil.convertColourToString(a.getColour()));
}
}

View File

@@ -0,0 +1,97 @@
package be.seeseepuff.allowanceplanner.service;
import be.seeseepuff.allowanceplanner.dto.MigrationDto;
import be.seeseepuff.allowanceplanner.repository.AllowanceRepository;
import be.seeseepuff.allowanceplanner.repository.HistoryRepository;
import be.seeseepuff.allowanceplanner.repository.TaskRepository;
import be.seeseepuff.allowanceplanner.repository.UserRepository;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MigrationService {
private final UserRepository userRepository;
private final AllowanceRepository allowanceRepository;
private final HistoryRepository historyRepository;
private final TaskRepository taskRepository;
private final EntityManager entityManager;
public MigrationService(UserRepository userRepository,
AllowanceRepository allowanceRepository,
HistoryRepository historyRepository,
TaskRepository taskRepository,
EntityManager entityManager) {
this.userRepository = userRepository;
this.allowanceRepository = allowanceRepository;
this.historyRepository = historyRepository;
this.taskRepository = taskRepository;
this.entityManager = entityManager;
}
@Transactional
public void importData(MigrationDto data) {
// Delete in dependency order
taskRepository.deleteAll();
historyRepository.deleteAll();
allowanceRepository.deleteAll();
userRepository.deleteAll();
// Insert users with original IDs using native SQL to bypass auto-increment
for (MigrationDto.MigrationUserDto u : data.users()) {
entityManager.createNativeQuery(
"INSERT INTO users (id, name, balance, weight) VALUES (:id, :name, :balance, :weight)")
.setParameter("id", u.id())
.setParameter("name", u.name())
.setParameter("balance", u.balance())
.setParameter("weight", u.weight())
.executeUpdate();
}
// Insert allowances with original IDs
for (MigrationDto.MigrationAllowanceDto a : data.allowances()) {
entityManager.createNativeQuery(
"INSERT INTO allowances (id, user_id, name, target, balance, weight, colour) VALUES (:id, :userId, :name, :target, :balance, :weight, :colour)")
.setParameter("id", a.id())
.setParameter("userId", a.userId())
.setParameter("name", a.name())
.setParameter("target", a.target())
.setParameter("balance", a.balance())
.setParameter("weight", a.weight())
.setParameter("colour", a.colour())
.executeUpdate();
}
// Insert history with original IDs
for (MigrationDto.MigrationHistoryDto h : data.history()) {
entityManager.createNativeQuery(
"INSERT INTO history (id, user_id, timestamp, amount, description) VALUES (:id, :userId, :timestamp, :amount, :description)")
.setParameter("id", h.id())
.setParameter("userId", h.userId())
.setParameter("timestamp", h.timestamp())
.setParameter("amount", h.amount())
.setParameter("description", h.description())
.executeUpdate();
}
// Insert tasks with original IDs
for (MigrationDto.MigrationTaskDto t : data.tasks()) {
entityManager.createNativeQuery(
"INSERT INTO tasks (id, name, reward, assigned, schedule, completed, next_run) VALUES (:id, :name, :reward, :assigned, :schedule, :completed, :nextRun)")
.setParameter("id", t.id())
.setParameter("name", t.name())
.setParameter("reward", t.reward())
.setParameter("assigned", t.assigned())
.setParameter("schedule", t.schedule())
.setParameter("completed", t.completed())
.setParameter("nextRun", t.nextRun())
.executeUpdate();
}
// Reset sequences so new inserts don't collide with migrated IDs
entityManager.createNativeQuery("SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 0))").getSingleResult();
entityManager.createNativeQuery("SELECT setval('allowances_id_seq', COALESCE((SELECT MAX(id) FROM allowances), 0))").getSingleResult();
entityManager.createNativeQuery("SELECT setval('history_id_seq', COALESCE((SELECT MAX(id) FROM history), 0))").getSingleResult();
entityManager.createNativeQuery("SELECT setval('tasks_id_seq', COALESCE((SELECT MAX(id) FROM tasks), 0))").getSingleResult();
}
}

View File

@@ -0,0 +1,120 @@
package be.seeseepuff.allowanceplanner.service;
import be.seeseepuff.allowanceplanner.dto.CreateTaskRequest;
import be.seeseepuff.allowanceplanner.dto.TaskDto;
import be.seeseepuff.allowanceplanner.entity.History;
import be.seeseepuff.allowanceplanner.entity.Task;
import be.seeseepuff.allowanceplanner.entity.User;
import be.seeseepuff.allowanceplanner.repository.HistoryRepository;
import be.seeseepuff.allowanceplanner.repository.TaskRepository;
import be.seeseepuff.allowanceplanner.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
public class TaskService {
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final HistoryRepository historyRepository;
private final AllowanceService allowanceService;
public TaskService(TaskRepository taskRepository,
UserRepository userRepository,
HistoryRepository historyRepository,
AllowanceService allowanceService) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.historyRepository = historyRepository;
this.allowanceService = allowanceService;
}
@Transactional
public int createTask(CreateTaskRequest request) {
Task task = new Task();
task.setName(request.name());
task.setReward(Math.round((request.reward() != null ? request.reward() : 0.0) * 100.0));
task.setAssigned(request.assigned());
task = taskRepository.save(task);
return task.getId();
}
public List<TaskDto> getTasks() {
return taskRepository.findByCompletedIsNull().stream()
.map(this::toDto)
.toList();
}
public Optional<TaskDto> getTask(int taskId) {
return taskRepository.findByIdAndCompletedIsNull(taskId)
.map(this::toDto);
}
@Transactional
public boolean updateTask(int taskId, CreateTaskRequest request) {
Optional<Task> opt = taskRepository.findByIdAndCompletedIsNull(taskId);
if (opt.isEmpty()) {
return false;
}
Task task = opt.get();
task.setName(request.name());
task.setReward(Math.round((request.reward() != null ? request.reward() : 0.0) * 100.0));
task.setAssigned(request.assigned());
taskRepository.save(task);
return true;
}
public boolean hasTask(int taskId) {
return taskRepository.existsById(taskId);
}
@Transactional
public void deleteTask(int taskId) {
taskRepository.deleteById(taskId);
}
@Transactional
public boolean completeTask(int taskId) {
Optional<Task> opt = taskRepository.findById(taskId);
if (opt.isEmpty()) {
return false;
}
Task task = opt.get();
long reward = task.getReward();
String rewardName = task.getName();
// Give reward to all users
List<User> users = userRepository.findAll();
for (User user : users) {
// Add history entry
History history = new History();
history.setUserId(user.getId());
history.setTimestamp(Instant.now().getEpochSecond());
history.setAmount(reward);
history.setDescription("Task completed: " + rewardName);
historyRepository.save(history);
// Distribute reward
allowanceService.addDistributedReward(user.getId(), (int) reward);
}
// Mark task as completed
task.setCompleted(Instant.now().getEpochSecond());
taskRepository.save(task);
return true;
}
private TaskDto toDto(Task t) {
return new TaskDto(
t.getId(),
t.getName(),
t.getReward() / 100.0,
t.getAssigned(),
t.getSchedule());
}
}

View File

@@ -0,0 +1,86 @@
package be.seeseepuff.allowanceplanner.service;
import be.seeseepuff.allowanceplanner.dto.TransferRequest;
import be.seeseepuff.allowanceplanner.entity.Allowance;
import be.seeseepuff.allowanceplanner.repository.AllowanceRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
public class TransferService {
private final AllowanceRepository allowanceRepository;
public TransferService(AllowanceRepository allowanceRepository) {
this.allowanceRepository = allowanceRepository;
}
@Transactional
public TransferResult transfer(TransferRequest request) {
if (request.from() == request.to()) {
return TransferResult.success();
}
int amountCents = (int) Math.round(request.amount() * 100.0);
if (amountCents <= 0) {
return TransferResult.badRequest("amount must be positive");
}
Optional<Allowance> fromOpt = allowanceRepository.findById(request.from());
if (fromOpt.isEmpty()) {
return TransferResult.notFound();
}
Optional<Allowance> toOpt = allowanceRepository.findById(request.to());
if (toOpt.isEmpty()) {
return TransferResult.notFound();
}
Allowance from = fromOpt.get();
Allowance to = toOpt.get();
if (from.getUserId() != to.getUserId()) {
return TransferResult.badRequest("Allowances do not belong to the same user");
}
long remainingTo = to.getTarget() - to.getBalance();
if (remainingTo <= 0) {
return TransferResult.badRequest("target already reached");
}
int transfer = amountCents;
if (transfer > remainingTo) {
transfer = (int) remainingTo;
}
if (from.getBalance() < transfer) {
return TransferResult.badRequest("Insufficient funds in source allowance");
}
from.setBalance(from.getBalance() - transfer);
to.setBalance(to.getBalance() + transfer);
allowanceRepository.save(from);
allowanceRepository.save(to);
return TransferResult.success();
}
public record TransferResult(Status status, String message) {
public static TransferResult success() {
return new TransferResult(Status.SUCCESS, "Transfer successful");
}
public static TransferResult badRequest(String message) {
return new TransferResult(Status.BAD_REQUEST, message);
}
public static TransferResult notFound() {
return new TransferResult(Status.NOT_FOUND, "Allowance not found");
}
public enum Status {
SUCCESS, BAD_REQUEST, NOT_FOUND
}
}
}

View File

@@ -0,0 +1,37 @@
package be.seeseepuff.allowanceplanner.service;
import be.seeseepuff.allowanceplanner.dto.UserDto;
import be.seeseepuff.allowanceplanner.dto.UserWithAllowanceDto;
import be.seeseepuff.allowanceplanner.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<UserDto> getUsers() {
return userRepository.findAll().stream()
.map(u -> new UserDto(u.getId(), u.getName()))
.toList();
}
public Optional<UserWithAllowanceDto> getUser(int userId) {
return userRepository.findById(userId)
.map(u ->
{
long totalAmount = userRepository.sumHistoryAmount(userId);
return new UserWithAllowanceDto(u.getId(), u.getName(), totalAmount / 100.0);
});
}
public boolean userExists(int userId) {
return userRepository.existsById(userId);
}
}

View File

@@ -0,0 +1,33 @@
package be.seeseepuff.allowanceplanner.util;
public class ColourUtil {
private ColourUtil() {
}
public static int convertStringToColour(String colourStr) {
if (colourStr == null || colourStr.isEmpty()) {
return 0xFF0000; // Default colour
}
if (colourStr.charAt(0) == '#') {
colourStr = colourStr.substring(1);
}
if (colourStr.length() != 6 && colourStr.length() != 3) {
throw new IllegalArgumentException("colour must be a valid hex string");
}
int colour = Integer.parseInt(colourStr, 16);
if (colourStr.length() == 3) {
int r = (colour & 0xF00) >> 8;
int g = (colour & 0x0F0) >> 4;
int b = (colour & 0x00F);
colour = (r << 20) | (g << 12) | (b << 4);
}
return colour;
}
public static String convertColourToString(Integer colour) {
if (colour == null) {
return "";
}
return String.format("#%06X", colour);
}
}

View File

@@ -0,0 +1,8 @@
spring.application.name=allowance-planner
spring.datasource.url=jdbc:postgresql://localhost:5432/allowance_planner
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true
server.port=8080

View File

@@ -0,0 +1,42 @@
CREATE TABLE users
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
weight DOUBLE PRECISION NOT NULL DEFAULT 10.0,
balance BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE history
(
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
timestamp BIGINT NOT NULL,
amount BIGINT NOT NULL,
description TEXT
);
CREATE TABLE allowances
(
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
target BIGINT NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
weight DOUBLE PRECISION NOT NULL,
colour INTEGER
);
CREATE TABLE tasks
(
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
reward BIGINT NOT NULL,
assigned INTEGER,
schedule TEXT,
completed BIGINT,
next_run BIGINT
);
INSERT INTO users (name)
VALUES ('Seeseemelk'),
('Huffle');

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<title>Allowance Planner 2000</title>
<style>
tr:hover {
background-color: #f0f0f0;
}
</style>
</head>
<body>
<h1>Allowance Planner 2000</h1>
<div th:if="${error != null}">
<h2>Error</h2>
<p th:text="${error}"></p>
</div>
<div th:if="${error == null}">
<h2>Users</h2>
<span th:each="user : ${users}">
<strong th:if="${currentUser != null and currentUser == user.id()}" th:text="${user.name()}"></strong>
<a th:href="@{/login(user=${user.id()})}"
th:text="${user.name()}" th:unless="${currentUser != null and currentUser == user.id()}"></a>
</span>
<div th:if="${currentUser != null and currentUser > 0}">
<h2>Allowances</h2>
<form action="/createAllowance" method="post">
<table border="1">
<thead>
<tr>
<th>Name</th>
<th>Progress</th>
<th>Target</th>
<th>Weight</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><label><input name="name" placeholder="Name" type="text"/></label></td>
<td></td>
<td><label><input name="target" placeholder="Target" type="number"/></label></td>
<td><label><input name="weight" placeholder="Weight" type="number"/></label></td>
<td><input type="submit" value="Create"/></td>
</tr>
<tr th:each="allowance : ${allowances}">
<td th:if="${allowance.id() == 0}">Total</td>
<td th:if="${allowance.id() != 0}" th:text="${allowance.name()}"></td>
<td th:if="${allowance.id() == 0}" th:text="${allowance.progress()}"></td>
<td th:if="${allowance.id() != 0}">
<progress th:max="${allowance.target()}" th:value="${allowance.progress()}"></progress>
(<span th:text="${allowance.progress()}"></span>)
</td>
<td th:if="${allowance.id() == 0}"></td>
<td th:if="${allowance.id() != 0}" th:text="${allowance.target()}"></td>
<td th:text="${allowance.weight()}"></td>
<td th:if="${allowance.id() != 0 and allowance.progress() >= allowance.target()}">
<a th:href="@{/completeAllowance(allowance=${allowance.id()})}">Mark as completed</a>
</td>
<td th:if="${allowance.id() == 0 or allowance.progress() < allowance.target()}"></td>
</tr>
</tbody>
</table>
</form>
<h2>Tasks</h2>
<form action="/createTask" method="post">
<table border="1">
<thead>
<tr>
<th>Name</th>
<th>Assigned</th>
<th>Reward</th>
<th>Schedule</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="task : ${tasks}">
<td th:text="${task.name()}"></td>
<td>
<span th:if="${task.assigned() == null}">None</span>
<span th:if="${task.assigned() != null}" th:text="${task.assigned()}"></span>
</td>
<td th:text="${task.reward()}"></td>
<td th:text="${task.schedule()}"></td>
<td>
<a th:href="@{/completeTask(task=${task.id()})}">Mark as completed</a>
</td>
</tr>
<tr>
<td><label><input name="name" placeholder="Name" type="text"/></label></td>
<td></td>
<td><label><input name="reward" placeholder="Reward" type="number"/></label></td>
<td><label><input name="schedule" placeholder="Schedule" type="text"/></label></td>
<td><input type="submit" value="Create"/></td>
</tr>
</tbody>
</table>
</form>
<h2>History</h2>
<table border="1">
<thead>
<tr>
<th>Timestamp</th>
<th>Allowance</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${history}">
<td th:text="${item.timestamp()}"></td>
<td th:text="${item.allowance()}"></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
package be.seeseepuff.allowanceplanner;
import be.seeseepuff.allowanceplanner.util.ColourUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ColourUtilTest {
@Test
void convertStringToColourWithSign() {
assertEquals(0x123456, ColourUtil.convertStringToColour("#123456"));
}
@Test
void convertStringToColourWithoutSign() {
assertEquals(0x123456, ColourUtil.convertStringToColour("123456"));
}
@Test
void convertStringToColourWithSignThreeDigits() {
assertEquals(0xA0B0C0, ColourUtil.convertStringToColour("#ABC"));
}
@Test
void convertStringToColourWithoutSignThreeDigits() {
assertEquals(0xA0B0C0, ColourUtil.convertStringToColour("ABC"));
}
}

View File

@@ -2,10 +2,11 @@ package main
import (
"fmt"
"github.com/gavv/httpexpect/v2"
"strconv"
"testing"
"time"
"github.com/gavv/httpexpect/v2"
)
const (
@@ -285,53 +286,53 @@ func TestCreateTask(t *testing.T) {
responseWithUser.Value("id").Number().IsEqual(2)
}
func TestCreateScheduleTask(t *testing.T) {
e := startServer(t)
// Create a new task without assigned user
requestBody := map[string]interface{}{
"name": "Test Task",
"reward": 100,
"schedule": "0 */5 * * * *",
}
response := e.POST("/tasks").
WithJSON(requestBody).
Expect().
Status(201). // Expect Created status
JSON().Object()
requestBody["schedule"] = "every 5 seconds"
e.POST("/tasks").WithJSON(requestBody).Expect().Status(400)
// Verify the response has an ID
response.ContainsKey("id")
response.Value("id").Number().IsEqual(1)
e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
// Get task
result := e.GET("/task/1").Expect().Status(200).JSON().Object()
result.Value("id").IsEqual(1)
result.Value("name").IsEqual("Test Task")
result.Value("schedule").IsEqual("0 */5 * * * *")
result.Value("reward").IsEqual(100)
result.Value("assigned").IsNull()
// Complete the task
e.POST("/task/1/complete").Expect().Status(200)
// Set expires date to 1 second in the past
db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec()
// Verify a new task is created
newTask := e.GET("/task/2").Expect().Status(200).JSON().Object()
newTask.Value("id").IsEqual(2)
newTask.Value("name").IsEqual("Test Task")
newTask.Value("schedule").IsEqual("0 */5 * * * *")
newTask.Value("reward").IsEqual(100)
newTask.Value("assigned").IsNull()
}
//func TestCreateScheduleTask(t *testing.T) {
// e := startServer(t)
//
// // Create a new task without assigned user
// requestBody := map[string]interface{}{
// "name": "Test Task",
// "reward": 100,
// "schedule": "0 */5 * * * *",
// }
//
// response := e.POST("/tasks").
// WithJSON(requestBody).
// Expect().
// Status(201). // Expect Created status
// JSON().Object()
//
// requestBody["schedule"] = "every 5 seconds"
// e.POST("/tasks").WithJSON(requestBody).Expect().Status(400)
//
// // Verify the response has an ID
// response.ContainsKey("id")
// response.Value("id").Number().IsEqual(1)
//
// e.GET("/tasks").Expect().Status(200).JSON().Array().Length().IsEqual(1)
//
// // Get task
// result := e.GET("/task/1").Expect().Status(200).JSON().Object()
// result.Value("id").IsEqual(1)
// result.Value("name").IsEqual("Test Task")
// result.Value("schedule").IsEqual("0 */5 * * * *")
// result.Value("reward").IsEqual(100)
// result.Value("assigned").IsNull()
//
// // Complete the task
// e.POST("/task/1/complete").Expect().Status(200)
//
// // Set expires date to 1 second in the past
// db.db.Query("update tasks set next_run = ? where id = 1").Bind(time.Now().Add(10 * -time.Minute).Unix()).MustExec()
//
// // Verify a new task is created
// newTask := e.GET("/task/2").Expect().Status(200).JSON().Object()
// newTask.Value("id").IsEqual(2)
// newTask.Value("name").IsEqual("Test Task")
// newTask.Value("schedule").IsEqual("0 */5 * * * *")
// newTask.Value("reward").IsEqual(100)
// newTask.Value("assigned").IsNull()
//}
func TestDeleteTask(t *testing.T) {
e := startServer(t)
@@ -963,3 +964,88 @@ func createTestAllowance(e *httpexpect.Expect, name string, target float64, weig
func createTestTask(e *httpexpect.Expect) int {
return createTestTaskWithAmount(e, 100)
}
// Transfer tests
func TestTransferSuccessful(t *testing.T) {
e := startServer(t)
// Create two allowances for user 1
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 100, 1)
// Add 30 to allowance 1
req := map[string]interface{}{"amount": 30, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Transfer 10 from 1 to 2
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
e.POST("/transfer").WithJSON(transfer).Expect().Status(200).JSON().Object().Value("message").IsEqual("Transfer successful")
// Verify balances
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("progress").Number().InDelta(20.0, 0.01)
allowances.Value(2).Object().Value("progress").Number().InDelta(10.0, 0.01)
}
func TestTransferCapsAtTarget(t *testing.T) {
e := startServer(t)
// Create two allowances
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 5, 1)
// Add 10 to allowance 1
req := map[string]interface{}{"amount": 10, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Transfer 10 from 1 to 2, but to only needs 5
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
e.POST("/transfer").WithJSON(transfer).Expect().Status(200)
// Verify capped transfer
allowances := e.GET("/user/1/allowance").Expect().Status(200).JSON().Array()
allowances.Value(1).Object().Value("progress").Number().InDelta(5.0, 0.01) // from had 10, transferred 5 -> left 5
allowances.Value(2).Object().Value("progress").Number().InDelta(5.0, 0.01) // to reached target
}
func TestTransferDifferentUsersFails(t *testing.T) {
e := startServer(t)
// Create allowance for user 1 and user 2
createTestAllowance(e, "User1 Allowance", 100, 1)
// create for user 2
e.POST("/user/2/allowance").WithJSON(CreateAllowanceRequest{Name: "User2 Allowance", Target: 100, Weight: 1}).Expect().Status(201)
// Add to user1 allowance
req := map[string]interface{}{"amount": 10, "description": "funds"}
e.POST("/user/1/allowance/1/add").WithJSON(req).Expect().Status(200)
// Attempt transfer between different users
transfer := map[string]interface{}{"from": 1, "to": 1 /* wrong id to simulate different user's id? */}
// To ensure different user, fetch the allowance id for user2 (it's 1 for user2 in its own context but global id will be 2)
// Create above for user2 produced global id 2, so use that
transfer = map[string]interface{}{"from": 1, "to": 2, "amount": 5}
e.POST("/transfer").WithJSON(transfer).Expect().Status(400)
}
func TestTransferInsufficientFunds(t *testing.T) {
e := startServer(t)
// Create two allowances
createTestAllowance(e, "From Allowance", 100, 1)
createTestAllowance(e, "To Allowance", 100, 1)
// Ensure from has 0 balance
transfer := map[string]interface{}{"from": 1, "to": 2, "amount": 10}
resp := e.POST("/transfer").WithJSON(transfer).Expect().Status(400).JSON().Object()
// Error text should mention insufficient funds
resp.Value("error").String().ContainsFold("insufficient")
}
func TestTransferNotFound(t *testing.T) {
e := startServer(t)
// No allowances exist yet (only user rows). Attempt transfer with non-existent IDs
transfer := map[string]interface{}{"from": 999, "to": 1000, "amount": 1}
e.POST("/transfer").WithJSON(transfer).Expect().Status(404)
}

View File

@@ -711,3 +711,127 @@ func (db *Db) AddAllowanceAmount(userId int, allowanceId int, request AddAllowan
return tx.Commit()
}
func (db *Db) TransferAllowance(fromId int, toId int, amount float64) error {
if fromId == toId {
return nil
}
amountCents := int(math.Round(amount * 100.0))
if amountCents <= 0 {
return fmt.Errorf("amount must be positive")
}
tx, err := db.db.Begin()
if err != nil {
return err
}
defer tx.MustRollback()
// Fetch from allowance (user_id, balance)
var fromUserId int
var fromBalance int
err = tx.Query("select user_id, balance from allowances where id = ?").Bind(fromId).ScanSingle(&fromUserId, &fromBalance)
if err != nil {
return err
}
// Fetch to allowance (user_id, target, balance)
var toUserId int
var toTarget int
var toBalance int
err = tx.Query("select user_id, target, balance from allowances where id = ?").Bind(toId).ScanSingle(&toUserId, &toTarget, &toBalance)
if err != nil {
return err
}
// Ensure same owner
if fromUserId != toUserId {
return fmt.Errorf(ErrDifferentUsers)
}
// Calculate how much the 'to' goal still needs
remainingTo := toTarget - toBalance
if remainingTo <= 0 {
// Nothing to transfer
return fmt.Errorf("target already reached")
}
// Limit transfer to what 'to' still needs
transfer := amountCents
if transfer > remainingTo {
transfer = remainingTo
}
// Ensure 'from' has enough balance
if fromBalance < transfer {
return fmt.Errorf(ErrInsufficientFunds)
}
// Perform updates
err = tx.Query("update allowances set balance = balance - ? where id = ? and user_id = ?").Bind(transfer, fromId, fromUserId).Exec()
if err != nil {
return err
}
err = tx.Query("update allowances set balance = balance + ? where id = ? and user_id = ?").Bind(transfer, toId, toUserId).Exec()
if err != nil {
return err
}
return tx.Commit()
}
func (db *Db) ExportAllData() (*ExportData, error) {
var err error
data := &ExportData{
Users: make([]ExportUser, 0),
Allowances: make([]ExportAllowance, 0),
History: make([]ExportHistory, 0),
Tasks: make([]ExportTask, 0),
}
for row := range db.db.Query("select id, name, balance, weight from users").Range(&err) {
u := ExportUser{}
if err = row.Scan(&u.ID, &u.Name, &u.Balance, &u.Weight); err != nil {
return nil, err
}
data.Users = append(data.Users, u)
}
if err != nil {
return nil, err
}
for row := range db.db.Query("select id, user_id, name, target, balance, weight, colour from allowances").Range(&err) {
a := ExportAllowance{}
if err = row.Scan(&a.ID, &a.UserID, &a.Name, &a.Target, &a.Balance, &a.Weight, &a.Colour); err != nil {
return nil, err
}
data.Allowances = append(data.Allowances, a)
}
if err != nil {
return nil, err
}
for row := range db.db.Query("select id, user_id, timestamp, amount, description from history").Range(&err) {
h := ExportHistory{}
if err = row.Scan(&h.ID, &h.UserID, &h.Timestamp, &h.Amount, &h.Description); err != nil {
return nil, err
}
data.History = append(data.History, h)
}
if err != nil {
return nil, err
}
for row := range db.db.Query("select id, name, reward, assigned, schedule, completed, next_run from tasks").Range(&err) {
t := ExportTask{}
if err = row.Scan(&t.ID, &t.Name, &t.Reward, &t.Assigned, &t.Schedule, &t.Completed, &t.NextRun); err != nil {
return nil, err
}
data.Tasks = append(data.Tasks, t)
}
if err != nil {
return nil, err
}
return data, nil
}

View File

@@ -80,3 +80,51 @@ type AddAllowanceAmountRequest struct {
Amount float64 `json:"amount"`
Description string `json:"description"`
}
type TransferRequest struct {
From int `json:"from"`
To int `json:"to"`
Amount float64 `json:"amount"`
}
type ExportUser struct {
ID int `json:"id"`
Name string `json:"name"`
Balance int64 `json:"balance"`
Weight float64 `json:"weight"`
}
type ExportAllowance struct {
ID int `json:"id"`
UserID int `json:"userId"`
Name string `json:"name"`
Target int64 `json:"target"`
Balance int64 `json:"balance"`
Weight float64 `json:"weight"`
Colour *int `json:"colour"`
}
type ExportHistory struct {
ID int `json:"id"`
UserID int `json:"userId"`
Timestamp int64 `json:"timestamp"`
Amount int64 `json:"amount"`
Description string `json:"description"`
}
type ExportTask struct {
ID int `json:"id"`
Name string `json:"name"`
Reward int64 `json:"reward"`
Assigned *int `json:"assigned"`
Schedule *string `json:"schedule"`
Completed *int64 `json:"completed"`
NextRun *int64 `json:"nextRun"`
}
type ExportData struct {
Users []ExportUser `json:"users"`
Allowances []ExportAllowance `json:"allowances"`
History []ExportHistory `json:"history"`
Tasks []ExportTask `json:"tasks"`
}

View File

@@ -3,31 +3,34 @@ module allowance_planner
go 1.24.2
require (
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0
gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0
github.com/adhocore/gronx v1.19.6
github.com/gavv/httpexpect/v2 v2.17.0
github.com/gin-contrib/cors v1.7.5
github.com/gin-gonic/gin v1.10.1
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/adhocore/gronx v1.19.6 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
@@ -35,44 +38,49 @@ require (
github.com/imkira/go-interpol v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sanity-io/litter v1.5.8 // indirect
github.com/sergi/go-diff v1.3.1 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.14 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.62.0 // indirect
github.com/valyala/fasthttp v1.67.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
github.com/yudai/gojsondiff v1.0.0 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.8 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.1 // indirect
modernc.org/sqlite v1.39.0 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
zombiezen.com/go/sqlite v1.4.2 // indirect
)

View File

@@ -1,21 +1,21 @@
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0 h1:aRItVfUj48fBmuec7rm/jY9KCfvHW2VzJfItVk4t8sw=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.14.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0 h1:+k0iBYM/aZJxz7++EKi/G9e66E9u4bPS3DFLrBeDb9Y=
gitea.seeseepuff.be/seeseemelk/mysqlite v0.15.0/go.mod h1:cgswydOxJjMlNwfcBIXnKjr47LwXnMT9BInkiHb0tXE=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -26,33 +26,33 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gavv/httpexpect/v2 v2.17.0 h1:nIJqt5v5e4P7/0jODpX2gtSw+pHXUqdP28YcjqwDZmE=
github.com/gavv/httpexpect/v2 v2.17.0/go.mod h1:E8ENFlT9MZ3Si2sfM6c6ONdwXV2noBCGkhA+lkJgkP0=
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -70,10 +70,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -93,8 +91,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
@@ -105,6 +103,10 @@ github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnI
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
@@ -112,31 +114,28 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
github.com/valyala/fasthttp v1.67.0 h1:tqKlJMUP6iuNG8hGjK/s9J4kadH7HLV4ijEcPGsezac=
github.com/valyala/fasthttp v1.67.0/go.mod h1:qYSIpqt/0XNmShgo/8Aq8E3UYWVVwNS2QYmzd8WIEPM=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -155,49 +154,51 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf
github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -212,18 +213,18 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q=
modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -232,18 +233,13 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
moul.io/http2curl/v2 v2.3.0 h1:9r3JfDzWPcbIklMOs2TnIFzDYvfAZvjeavG6EzP7jYs=
moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
zombiezen.com/go/sqlite v1.4.0 h1:N1s3RIljwtp4541Y8rM880qgGIgq3fTD2yks1xftnKU=
zombiezen.com/go/sqlite v1.4.0/go.mod h1:0w9F1DN9IZj9AcLS9YDKMboubCACkwYCGkzoy3eG5ik=
zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=
zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=

View File

@@ -4,15 +4,14 @@ import (
"context"
"embed"
"errors"
"fmt"
"gitea.seeseepuff.be/seeseemelk/mysqlite"
"github.com/adhocore/gronx"
"log"
"net"
"net/http"
"os"
"strconv"
"gitea.seeseepuff.be/seeseemelk/mysqlite"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
@@ -26,6 +25,8 @@ const (
ErrInvalidUserID = "Invalid user ID"
ErrUserNotFound = "User not found"
ErrCheckingUserExist = "Error checking user existence: %v"
ErrInsufficientFunds = "Insufficient funds in source allowance"
ErrDifferentUsers = "Allowances do not belong to the same user"
)
// ServerConfig holds configuration for the server.
@@ -50,6 +51,16 @@ const DefaultDomain = "localhost:8080"
// The domain that the server is reachable at.
var domain = DefaultDomain
func exportData(c *gin.Context) {
data, err := db.ExportAllData()
if err != nil {
log.Printf("Error exporting data: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
c.IndentedJSON(http.StatusOK, data)
}
func getUsers(c *gin.Context) {
users, err := db.GetUsers()
if err != nil {
@@ -439,11 +450,8 @@ func createTask(c *gin.Context) {
}
if taskRequest.Schedule != nil {
valid := gronx.IsValid(*taskRequest.Schedule)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid cron schedule: %s", *taskRequest.Schedule)})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "Schedules are not yet supported"})
return
}
// If assigned is not nil, check if user exists
@@ -653,6 +661,32 @@ func getHistory(c *gin.Context) {
c.IndentedJSON(http.StatusOK, history)
}
func transfer(c *gin.Context) {
var transferRequest TransferRequest
if err := c.ShouldBindJSON(&transferRequest); err != nil {
log.Printf("Error parsing request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err := db.TransferAllowance(transferRequest.From, transferRequest.To, transferRequest.Amount)
if err != nil {
if errors.Is(err, mysqlite.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Allowance not found"})
return
}
if err.Error() == ErrInsufficientFunds || err.Error() == ErrDifferentUsers {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("Error transferring allowance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": ErrInternalServerError})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Transfer successful"})
}
/*
Initialises the database, and then starts the server.
If the context gets cancelled, the server is shutdown and the database is closed.
@@ -688,6 +722,8 @@ func start(ctx context.Context, config *ServerConfig) {
router.PUT("/api/task/:taskId", putTask)
router.DELETE("/api/task/:taskId", deleteTask)
router.POST("/api/task/:taskId/complete", completeTask)
router.POST("/api/transfer", transfer)
router.GET("/api/export", exportData)
srv := &http.Server{
Addr: config.Addr,

View File

@@ -409,6 +409,59 @@ paths:
404:
description: The task could not be found.
/api/transfer:
post:
summary: Transfer amount between allowances
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
from:
type: integer
description: Source allowance ID
to:
type: integer
description: Destination allowance ID
amount:
type: number
format: float
description: Amount to transfer
required:
- from
- to
- amount
responses:
'200':
description: Transfer successful
content:
application/json:
schema:
type: object
properties:
message:
type: string
'400':
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error:
type: string
'404':
description: Allowance not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
components:
schemas:
task:

View File

@@ -6,6 +6,7 @@
</div>
<ion-title *ngIf="isAddMode">Create Task</ion-title>
<ion-title *ngIf="!isAddMode">Edit Task</ion-title>
<button class="done-button" *ngIf="!isAddMode" (click)="completeAndRecreateTask()">Done & Re-create</button>
</div>
</ion-toolbar>
</ion-header>

View File

@@ -42,6 +42,13 @@ button {
margin-top: 100px;
}
.done-button {
width: 150px;
margin-top: unset;
margin-right: 20px;
border-radius: 10px;
}
button:disabled,
button[disabled]{
opacity: 0.5;

View File

@@ -57,13 +57,13 @@ export class EditTaskPage implements OnInit {
let assigned: number | null = Number(formValue.assigned);
if (assigned === 0) {
assigned = null;
}
};
const task = {
name: formValue.name,
reward: formValue.reward,
assigned
}
};
if (this.isAddMode) {
this.taskService.createTask(task);
@@ -79,6 +79,25 @@ export class EditTaskPage implements OnInit {
this.router.navigate(['/tabs/tasks']);
}
completeAndRecreateTask() {
const formValue = this.form.value;
let assigned: number | null = Number(formValue.assigned);
if (assigned === 0) {
assigned = null;
};
const task = {
name: formValue.name,
reward: formValue.reward,
assigned
};
this.taskService.createTask(task);
this.taskService.completeTask(this.id);
this.router.navigate(['/tabs/tasks']);
}
navigateBack() {
this.location.back();
}

View File

@@ -15,6 +15,6 @@
<div
class="amount"
[ngClass]="{ 'negative': history.allowance < 0 }"
>{{ history.allowance }} SP</div>
>{{ history.allowance.toFixed(2) }} SP</div>
</div>
</ion-content>